Skip to main content

Create a buyer-linked token

By default, a token identifies only your agent. A token that also carries the identity of a signed-in Shop customer is a buyer-linked token.

This page shows how to turn a signed-in Shop session into a buyer-linked token using the UCP delegated identity provider (IdP) flow, where Shop (accounts.shop.app) is the delegated IdP that Shopify trusts. Instead of sending the customer through a second browser authorization, your agent chains the existing Shop identity to Shopify.

Caution

Your Dev Dashboard client secret is confidential. Run steps 2 and 3 and the helper below on a server you control. Never run them in browser or mobile code, where the secret can be recovered from shipped source or network traffic. Public clients can't complete this flow safely.


  • Dev Dashboard client credentials (a client ID and secret) with a redirect URI configured. You reuse these same credentials to authorize customers with Shop in step 1.

This flow chains the customer's existing Shop identity to Shopify, following RFC 8693 and RFC 7523.

Don't hard-code endpoints or provider details. First, resolve them at runtime using UCP's existing discovery protocols:

  • Shopify's authorization server: Read the OAuth protected resource metadata of the resource server you're calling, served at /.well-known/oauth-protected-resource. For the Global Catalog (catalog.shopify.com), it points to api.shopify.com, Shopify's global authorization server. For a merchant store, it points to that store's own authorization server. For example, snowdevil.myshopify.com points to https://shopify.com/authentication/14168. Each authorization server publishes its own metadata at /.well-known/oauth-authorization-server. For a store's path-scoped issuer, the well-known segment goes between the host and the path: https://shopify.com/.well-known/oauth-authorization-server/authentication/14168.
  • Shop as the delegated IdP: Read the dev.ucp.common.identity_linking capability in Shopify's UCP business profile. It identifies Shop as the delegated identity provider and gives its auth_url.
  • Shop's endpoints: Read Shop's published OAuth authorization server metadata. It gives Shop's authorization_endpoint and token_endpoint.

Avoid unnecessary network requests by observing the cache directives in the responses.

Then run three steps. The first authorizes the customer with Shop; the rest chain that identity to Shopify:

  1. Authorize with Shop. Run a standard OAuth authorization code flow against Shop to get a Shop access token for the signed-in customer.
  2. Exchange at Shop. Exchange the Shop access token at Shop's token endpoint for a short-lived JWT authorization grant, audience-restricted to Shopify.
  3. Redeem at Shopify. Present that grant to Shopify's token endpoint to receive a buyer-linked token.

Anchor to Step 1: Authorize the customer with ShopStep 1: Authorize the customer with Shop

Run a standard OAuth 2.0 authorization code flow against Shop (accounts.shop.app) to get a Shop access token for the signed-in customer. Reuse the same client ID and secret from your Dev Dashboard app (Shop recognizes them) along with the redirect URI you configured there.

Discover Shop's authorization_endpoint and token_endpoint from its authorization server metadata rather than hard-coding them.

First, redirect the customer to Shop's authorization_endpoint. Include a state value to protect against CSRF. Request every scope you plan to redeem for a buyer-linked token. For example, include dev.ucp.shopping.checkout:manage if you'll call checkout tools on a merchant store:

Authorization request

https://accounts.shop.app/oauth/authorize?response_type=code&client_id={your_client_id}&redirect_uri={your_redirect_uri}&scope=openid%20dev.ucp.shopping.catalog.search:read%20dev.ucp.shopping.checkout:manage&state={state}

Percent-encode each parameter value. The spaces separating the scope values are encoded as %20, and you must encode {your_redirect_uri} and {state} when you substitute them.

After the customer approves, Shop redirects to your redirect URI with an authorization code in the code parameter. Exchange it for a Shop access token at Shop's token_endpoint:

curl --request POST \
--url https://accounts.shop.app/oauth/token \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=authorization_code' \
--data-urlencode 'code={authorization_code}' \
--data-urlencode 'redirect_uri={your_redirect_uri}' \
--data-urlencode 'client_id={your_client_id}' \
--data-urlencode 'client_secret={your_client_secret}'
{
"access_token": "{shop_access_token}",
"token_type": "Bearer",
"expires_in": 3600
}

Anchor to Step 2: Exchange the Shop token for an authorization grantStep 2: Exchange the Shop token for an authorization grant

Discover Shop's token_endpoint from its authorization server metadata, then send a token-exchange request. Set the audience to the target you'll redeem the grant at, so it can't be redeemed anywhere else. Use api.shopify.com for the Global Catalog, or the store's domain, such as snowdevil.myshopify.com, for a merchant store. Shop audience-restricts the returned grant to that target's authorization server. This is a token-endpoint request, so authenticate it with the same client ID and secret you used to authorize the customer:

curl --request POST \
--url https://accounts.shop.app/oauth/token \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \
--data-urlencode 'subject_token={shop_access_token}' \
--data-urlencode 'subject_token_type=urn:ietf:params:oauth:token-type:access_token' \
--data-urlencode 'requested_token_type=urn:ietf:params:oauth:token-type:jwt' \
--data-urlencode 'audience=api.shopify.com' \
--data-urlencode 'client_id={your_client_id}' \
--data-urlencode 'client_secret={your_client_secret}'
{
"issued_token_type": "urn:ietf:params:oauth:token-type:jwt",
"access_token": "{jwt_authorization_grant}",
"token_type": "N_A",
"expires_in": 60
}

The access_token returned here is the JWT authorization grant. It's a signed, single-use, short-lived assertion, not an access token. Its only purpose is to be redeemed in the next step.


Anchor to Step 3: Redeem the grant for a buyer-linked tokenStep 3: Redeem the grant for a buyer-linked token

Present the grant to Shopify's token endpoint as a JWT bearer assertion, requesting only the scopes you need. Authenticate the request with the same client ID and secret:

curl --request POST \
--url https://api.shopify.com/auth/access_token \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer' \
--data-urlencode 'assertion={jwt_authorization_grant}' \
--data-urlencode 'scope=dev.ucp.shopping.catalog.search:read' \
--data-urlencode 'client_id={your_client_id}' \
--data-urlencode 'client_secret={your_client_secret}'
{
"access_token": "{buyer_linked_token}"
}

The same steps work for any Shopify merchant store. What changes is where you redeem the grant. The store's protected resource metadata points to its own authorization server instead of api.shopify.com, and you set the store's domain as the audience when you exchange the Shop token. For example, redeem the grant at snowdevil.myshopify.com's authorization server, requesting the openid and dev.ucp.shopping.checkout:manage scopes to sign the buyer in to checkout tools:

curl --request POST \
--url https://shopify.com/authentication/14168/oauth/token \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer' \
--data-urlencode 'assertion={jwt_authorization_grant}' \
--data-urlencode 'scope=openid dev.ucp.shopping.checkout:manage' \
--data-urlencode 'client_id={your_client_id}' \
--data-urlencode 'client_secret={your_client_secret}'
{
"access_token": "{buyer_linked_token}"
}

In both cases, Shopify verifies the grant's signature against Shop's published keys, checks its claims, resolves the Shop customer, and issues a buyer-linked token. Like an app-only token, it's a JWT that expires after 60 minutes. Buyer-linked tokens aren't refreshed. When one expires, repeat these steps to mint a fresh token from the customer's Shop session.


The following helper resolves every endpoint through the discovery protocols, then performs the exchange and redemption to return a buyer-linked token, starting from the Shop access token and your Dev Dashboard client credentials. Discovery starts from the resource server you're calling, whether that's the Global Catalog or a merchant store. This example defaults to the Global Catalog (catalog.shopify.com). To adapt it for a merchant store, set the resource server to the store's domain: discovery resolves the store's authorization server and the grant's audience:

// Server-side only: this helper uses the confidential client secret and must
// never run in browser or mobile code.

// Discovery starts from the resource server you're calling. For a merchant
// store, use its domain instead, for example 'https://snowdevil.myshopify.com'.
const RESOURCE_SERVER = 'https://catalog.shopify.com';

async function getJson(url) {
const res = await fetch(url);
if (!res.ok) throw new Error(`${res.status} fetching ${url}`);
return res.json();
}

// Discovery 1: find the authorization server from the resource server's
// OAuth protected resource metadata, then read its token endpoint. Per
// RFC 8414, the well-known segment goes between the issuer's host and path.
async function discoverShopifyAuthServer() {
const { authorization_servers } = await getJson(
`${RESOURCE_SERVER}/.well-known/oauth-protected-resource`,
);
const issuer = new URL(authorization_servers[0]);
const path = issuer.pathname === '/' ? '' : issuer.pathname;
const metadata = await getJson(
`${issuer.origin}/.well-known/oauth-authorization-server${path}`,
);
// The grant's audience: Shopify's global authorization server is
// identified by its host, and a merchant store by the store's domain.
const audience = path ? new URL(RESOURCE_SERVER).host : issuer.host;
return { audience, tokenEndpoint: metadata.token_endpoint };
}

// Discovery 2: find Shop as the delegated IdP from the identity linking
// capability in Shopify's UCP business profile.
async function discoverShopAuthUrl() {
const { capabilities } = (await getJson(`${RESOURCE_SERVER}/.well-known/ucp`)).ucp;
const [identityLinking] = capabilities['dev.ucp.common.identity_linking'];
const [provider] = Object.values(identityLinking.config.providers)
.flat()
.filter((p) => p.type === 'oauth2');
return provider.auth_url; // https://accounts.shop.app
}

// Discovery 3: read Shop's OAuth endpoints from its authorization server
// metadata. Its authorization_endpoint drives the step 1 browser redirect.
async function discoverShopEndpoints(shopAuthUrl) {
return getJson(`${shopAuthUrl}/.well-known/oauth-authorization-server`);
}

// Step 2: exchange the Shop access token for a JWT authorization grant (RFC 8693).
async function getAuthorizationGrant({ tokenEndpoint, audience, shopAccessToken, clientId, clientSecret }) {
const res = await fetch(tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
subject_token: shopAccessToken,
subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
requested_token_type: 'urn:ietf:params:oauth:token-type:jwt',
audience,
client_id: clientId,
client_secret: clientSecret,
}),
});
const { access_token } = await res.json();
return access_token; // the JWT authorization grant
}

// Step 3: discover every endpoint, then redeem the grant for a buyer-linked token (RFC 7523).
export async function getBuyerLinkedToken({ shopAccessToken, clientId, clientSecret, scope }) {
const shopify = await discoverShopifyAuthServer();
const shopAuthUrl = await discoverShopAuthUrl();
const { token_endpoint: shopTokenEndpoint } = await discoverShopEndpoints(shopAuthUrl);

// Step 1 (omitted): run a standard OAuth authorization code flow against
// Shop's authorization_endpoint (from discovery 3) to obtain `shopAccessToken`
// for the signed-in customer. See "Step 1: Authorize the customer with Shop" above.

const assertion = await getAuthorizationGrant({
tokenEndpoint: shopTokenEndpoint,
audience: shopify.audience,
shopAccessToken,
clientId,
clientSecret,
});

const res = await fetch(shopify.tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion,
client_id: clientId,
client_secret: clientSecret,
scope,
}),
});
const { access_token } = await res.json();
return access_token; // buyer-linked token, valid ~60 minutes
}
# Discovery starts from the resource server you're calling. For a merchant
# store, use its domain instead, for example "https://snowdevil.myshopify.com".
RESOURCE_SERVER="https://catalog.shopify.com"

# Discovery 1: the authorization server, from the resource server's OAuth
# protected resource metadata. Per RFC 8414, the well-known segment goes
# between the issuer's host and path.
SHOPIFY_ISSUER=$(curl --silent "$RESOURCE_SERVER/.well-known/oauth-protected-resource" \
| jq -r '.authorization_servers[0]')
SHOPIFY_TOKEN_ENDPOINT=$(curl --silent "$(echo "$SHOPIFY_ISSUER" \
| sed -E 's#^(https://[^/]+)#\1/.well-known/oauth-authorization-server#')" \
| jq -r '.token_endpoint')

# The grant's audience: Shopify's global authorization server is identified
# by its host, and a merchant store by the store's domain.
case "$SHOPIFY_ISSUER" in
https://*/*) AUDIENCE=$(echo "$RESOURCE_SERVER" | sed -E 's#^https?://##') ;;
*) AUDIENCE=$(echo "$SHOPIFY_ISSUER" | sed -E 's#^https?://##') ;;
esac

# Discovery 2: Shop as the delegated IdP, from the identity linking capability.
SHOP_AUTH_URL=$(curl --silent "$RESOURCE_SERVER/.well-known/ucp" \
| jq -r '[.ucp.capabilities["dev.ucp.common.identity_linking"][0].config.providers[][] | select(.type == "oauth2") | .auth_url][0]')

# Discovery 3: Shop's token endpoint, from its authorization server metadata.
SHOP_TOKEN_ENDPOINT=$(curl --silent "$SHOP_AUTH_URL/.well-known/oauth-authorization-server" \
| jq -r '.token_endpoint')

# Step 1 (omitted): run a standard OAuth authorization code flow against Shop's
# authorization_endpoint to obtain $SHOP_ACCESS_TOKEN for the signed-in customer.
# See "Step 1: Authorize the customer with Shop" above.

# Step 2: exchange the Shop access token for a JWT authorization grant (RFC 8693).
GRANT=$(curl --silent --request POST \
--url "$SHOP_TOKEN_ENDPOINT" \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \
--data-urlencode "subject_token=$SHOP_ACCESS_TOKEN" \
--data-urlencode 'subject_token_type=urn:ietf:params:oauth:token-type:access_token' \
--data-urlencode 'requested_token_type=urn:ietf:params:oauth:token-type:jwt' \
--data-urlencode "audience=$AUDIENCE" \
--data-urlencode "client_id=$CLIENT_ID" \
--data-urlencode "client_secret=$CLIENT_SECRET" \
| jq -r '.access_token')

# Step 3: redeem the grant for a buyer-linked token (RFC 7523).
curl --silent --request POST \
--url "$SHOPIFY_TOKEN_ENDPOINT" \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer' \
--data-urlencode "assertion=$GRANT" \
--data-urlencode 'scope=dev.ucp.shopping.catalog.search:read' \
--data-urlencode "client_id=$CLIENT_ID" \
--data-urlencode "client_secret=$CLIENT_SECRET"
{
"access_token": "{buyer_linked_token}"
}


Was this page helpful?