Authentication and Access Scopes

This content was generated with the assistance of AI and should be verified against the official documentation of each platform before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page was written against the GraphQL Admin API version 2026-10 (the latest stable version, released on 2026-10-01), Shopify CLI 5.0.0, @shopify/shopify-app-react-router 4.0.0 (the public app template still depends on ^3.0.1), @shopify/shopify-api 15.0.1 and @shopify/app-bridge-react 4.2.14, and checked on 2026-10-27. It applies to every Shopify plan, because authentication belongs to the app and not to the merchant’s plan, with one exception that is called out where it matters: the data a custom app created in the admin can read depends on the plan.

Every app that calls the GraphQL Admin API has to answer three questions on each request: which app is calling, which merchant approved it, and what is it allowed to do. This page covers the credentials that answer them (the client secret, the ID token, the access token and the refresh token), the grants that produce them (token exchange, authorization code grant and client credentials grant), how Shopify installs an app and records its scopes (managed installation), the lifetimes and the January 2027 deadline for expiring offline tokens, the access scopes that bound what a token can do, and the protected customer data review that gates personal data. It sits after the page on app scaffolding and before the pages on webhooks and the Admin GraphQL API, which use the tokens described here. The Pocket Coffee shop (Spanish and English storefronts, the Coffee Club group, the roast date badge and the "order placed" logger) is the running example: its companion app declares the scopes, exchanges tokens and logs orders.

Two terminology changes matter when reading older material. What Shopify called a session token is now an ID token; the same JWT is documented under the new name, and App Bridge 2.0 and older guides still say session token. "Managed pricing" and the Billing API changed names as well, but that is a billing topic. The REST Admin API and the checkout.liquid page are legacy and are not repeated here.

This page was prepared without a Shopify account, so nothing here was executed against a real store, the Partner Dashboard or the Dev Dashboard. What did run locally is listed in What was run and what was not: the shopify.app.toml build, the GraphQL documents validated against the downloaded Admin 2026-10 schema, an ID token validator, and a token endpoint client exercised against a local stand-in server.

The credentials and where each one lives

A Shopify app uses several different secrets, and most authentication bugs come from mixing them up. The table lists every credential that appears in this page, what it proves, how long it lives and where it must (and must not) go.

Credential What it proves Lifetime Where it lives

Client ID

Identifies the app. Public. It is the aud claim of an ID token and the App Bridge API key.

Until the app is deleted

Frontend, TOML file, URLs

Client secret

Proves a request comes from the app. Signs and verifies ID tokens (HS256) and webhook HMACs.

Until rotated (rotation invalidates tokens tied to the old secret)

Backend secret store only. Never in a bundle, repository or log

ID token

A logged-in Shopify user is making this request from this store. Carries no permissions and cannot call any API.

1 minute

Browser to your backend, in the Authorization header. Never to a third party

Offline access token

The app may act on the store with the scopes the merchant approved, with no user session behind it.

Expiring: 1 hour. Non-expiring: until uninstall or secret revoked

Backend database, encrypted at rest, one record per shop

Refresh token

Lets the backend replace an expiring offline access token without a user.

90 days, replaced on every use

Backend database, same protection as the access token

Online access token

The same as an access token, narrowed to what one staff member may do.

24 hours or until that user logs out of the admin

Backend, keyed by shop and user

Client credentials token

The app acts as itself on a store in its own organization.

24 hours (expires_in 86399)

Backend memory or cache

Delegate access token

A scoped-down copy of an offline token for one subsystem.

Inherits the parent’s remaining lifetime or the shorter expiresIn requested

The subsystem that needs it only

Offline and online access tokens are opaque strings that begin with shpat_, whichever grant produced them; delegate access tokens begin with shppa_. Treat the prefix as a convenience for secret scanners and never as something to parse. The Storefront API uses its own tokens (a public token and a private token with their own headers) and the Customer Account API uses a customer OAuth flow; both are covered in the Storefront and headless page. The Theme Access password and the app automation token used in continuous integration are covered with the CLI workflows in theme tooling and deployment.

The request flow in one pass

For any app with a backend of its own, a GraphQL Admin API request goes through the same three steps, whatever the grant:

  1. The app obtains an access token for the store it is calling.

  2. The app sends it in the X-Shopify-Access-Token header on every request to https://<shop>.myshopify.com/admin/api/2026-10/graphql.json.

  3. Shopify returns the data that the token’s access scopes allow, or an error when the request fails one of the access requirements.

The access requirements are checked in order of how often they bite: a valid token; the scope the field names; for a small number of scopes, Shopify’s approval of that scope for the app; for an online token, the staff member’s own permissions (a request can be refused with ACCESS_DENIED even when the app holds the scope); and, for fields that return personal data, the approval to use protected customer data.

An extension-only app has no backend, so it never holds a token. Shopify authenticates its requests through Direct API access, where App Bridge signs each call (see Online access tokens and per-user permissions).

The sequence below is the path of an embedded app built with the CLI, from the merchant clicking Install to the first GraphQL call and the refresh that follows. The numbered steps are referred to in the sections after it.

sequenceDiagram autonumber actor M as Merchant participant S as Shopify (install and admin) participant AB as App Bridge (app iframe) participant B as App backend participant T as Token endpoint participant G as Admin GraphQL API M->>S: Install app S->>M: Show scopes from shopify.app.toml M->>S: Approve scopes Note over S: Managed installation: Shopify records the grant without calling the app S-->>M: Open the app embedded in the admin M->>AB: App loads AB->>AB: shopify.idToken() returns a JWT valid for 1 minute AB->>B: Request with Authorization Bearer ID token B->>B: Verify HS256 signature, exp, nbf, aud, iss and dest B->>T: POST token exchange (subject_token, expiring=1) T-->>B: access_token, expires_in 3600, refresh_token, refresh_token_expires_in B->>B: Store the pair per shop, encrypted B->>G: GraphQL with X-Shopify-Access-Token G-->>B: data limited to approved scopes Note over B,T: Later, with no merchant session (webhook, job) B->>T: POST grant_type refresh_token T-->>B: new access token and new refresh token

Choosing a grant

Shopify supports three grants. They all end at the same endpoint, POST https://<shop>.myshopify.com/admin/oauth/access_token, and the token you get and the GraphQL calls you make with it are the same whichever grant produced it. Choose by where the app runs and whose stores it acts on.

If the app Grant Notes

Runs embedded in the Shopify admin and was built with Shopify CLI

Token exchange

The template does it for you through authenticate.admin(request). Installation uses managed installation.

Runs embedded in the admin without a Shopify template

Token exchange

Implement it yourself, or use an official library (@shopify/shopify-api) that verifies requests and exchanges tokens.

Runs outside the admin (standalone, API-only, non-embedded)

Authorization code grant

The merchant is redirected to Shopify to approve, then back with a code. The only case that still needs OAuth callback routes.

Is a server-side integration for stores in your own organization

Client credentials grant

No install redirect and no merchant approval. Works only on stores in the developer’s organization.

Is extension-only, with no backend

None (Direct API access)

Shopify authenticates the extension’s calls; there is no token to store.

Every option except the client credentials grant works on any merchant’s store, including clients' stores. Apps that were created directly in the Shopify admin ("custom apps" from Settings, Apps and sales channels) get a pre-generated token when the merchant installs them; new apps of that kind can no longer be created, but existing ones keep working.

Shopify managed installation

Managed installation is how apps built with the CLI are installed: the scopes live in shopify.app.toml, and Shopify itself shows the consent screen, installs the app and updates the granted scopes, without redirecting the merchant through an OAuth URL that the app implements. The app is never called during installation, so there is no /auth/callback route to write. This is why a CLI-built app does not implement the authorization code grant.

Declare the scopes and deploy:

# shopify.app.toml (Pocket Coffee companion app; placeholder identifiers)
client_id = "00000000000000000000000000000000"
name = "pocket-coffee-app"
handle = "pocket-coffee-app"
application_url = "https://app.example.com"
embedded = true

[access_scopes]
scopes = "write_products,read_inventory,read_orders,read_locales"
optional_scopes = ["read_customers", "read_all_orders"]

[auth]
redirect_urls = ["https://app.example.com/api/auth"]

[webhooks]
api_version = "2026-10"

  [[webhooks.subscriptions]]
  topics = ["app/uninstalled"]
  uri = "/webhooks/app/uninstalled"

  [[webhooks.subscriptions]]
  topics = ["app/scopes_update"]
  uri = "/webhooks/app/scopes_update"

  [[webhooks.subscriptions]]
  compliance_topics = ["customers/data_request", "customers/redact", "shop/redact"]
  uri = "/webhooks/compliance"
shopify app deploy

The keys that matter for authentication are these:

Key Required Meaning

client_id

Yes

The app’s public identifier. Placeholder here; a real value is created by linking the config to an app in the Dev Dashboard.

application_url

Yes

Where the embedded app is served. The App Bridge iframe loads it.

embedded

Yes (boolean)

true for an app that runs in the admin and uses ID tokens. A string such as "yes" fails validation with Value must be Boolean.

[access_scopes] scopes

Yes (comma-separated string)

Scopes the merchant must grant at install. An empty string is allowed when every scope is optional.

[access_scopes] optional_scopes

No (array of strings)

Scopes the app may request later with the merchant’s consent. They are granted separately, after installation.

[access_scopes] use_legacy_install_flow

No (boolean)

false or omitted selects managed installation. true selects the legacy flow, where scopes travel as a URL parameter during the authorization code grant. Still supported, but not recommended because installs can end up with different scopes.

[auth] redirect_urls

Yes

Redirect targets the app may use for authentication flows.

[access.admin] direct_api_mode

No

online (default) or offline: which token Direct API access uses when embedded_app_direct_api_access = true.

[access.admin] embedded_app_direct_api_access

No (boolean)

Enables calling the Admin API from the app frontend with fetch('shopify:admin/api/2026-10/graphql.json'), signed by App Bridge.

[customer_authentication]

No

redirect_uris, javascript_origins and logout_urls for apps that sign customers in through the Customer Account API.

[webhooks] api_version and

No

topics, compliance_topics, uri, filter, include_fields. The app/uninstalled and app/scopes_update topics keep stored tokens and scopes in step.

What the install does, step by step:

  1. The merchant clicks Install from the App Store listing, a custom distribution link or the Dev Dashboard install page. Shopify shows the scopes in scopes (and says which are optional).

  2. The merchant approves. Shopify records the grant and associates the app with the shop. If the app asked for online access by default, the installing staff member must hold every required scope, or the installation fails.

  3. Shopify opens the embedded app. The first request carries an ID token; the app exchanges it for its first access token. There is no separate "installation" call into the app.

  4. When the developer changes scopes and deploys, merchants who already have the app approve the new scopes the next time they open it. Reductions need no prompt: the app loses the scopes when the merchant next opens it. The app/scopes_update webhook fires when the merchant approves (or when the app is opened after a reduction).

Apps created in the Dev Dashboard without the CLI declare scopes on an app version instead; releasing the version deploys them. Apps that implement the authorization code grant themselves pass a scope parameter on the authorization URL. In every case the merchant approves new scopes.

The CLI validates this file offline. shopify app build --no-input accepted the file above and reported the app as built; with scopes given as an array and optional_scopes given as a string it reported both mistakes without contacting Shopify:

$ shopify app build --no-input
 pocket-coffee-app built!

$ shopify app build --no-input     (scopes = ["read_products"], optional_scopes = "read_customers")
 [access_scopes.scopes]: Expected string, received array
 [access_scopes.optional_scopes]: Expected array, received string

ID tokens (formerly session tokens)

An embedded app runs in an iframe in the admin, where third-party cookies are not reliably available, so it cannot keep a cookie session. Instead App Bridge issues an ID token: a short-lived JWT that proves a request comes from an authenticated Shopify user on a specific store. Apps embedded in the admin must authenticate with ID tokens, and Shopify’s automated app review checks that the app fetches one (an ad blocker can stop App Bridge from issuing it, which stalls that check).

An ID token authenticates the user, not the app. It carries no permissions and is never sent to a Shopify API: the backend validates it and exchanges it for an access token. It expires one minute after issue, so the frontend fetches a fresh one for each request instead of caching it; App Bridge may return a cached token that has less than the full minute left.

Getting and sending the token

The browser side is one call. In App Home use shopify.idToken(); in an admin UI extension use auth.idToken(). App Bridge’s fetch interceptor adds the token automatically to requests to the app’s own domain, so a plain fetch from the app frontend already carries an Authorization: Bearer <id-token> header.

// public/app.js
const idToken = await shopify.idToken();

const response = await fetch('/exchange/offline', {
  method: 'POST',
  headers: { Authorization: `Bearer ${idToken}` },
});

Call shopify.idToken() yourself only when a request does not go through fetch, for example to authenticate a WebSocket connection to your backend. Send ID tokens to your own backend only: the signature can be verified only with the client secret, so a third party could check it only if you gave it a credential that also lets it mint tokens your app would accept, and the token is a bearer credential that can be replayed until it expires.

Claims to validate

App templates and the official libraries validate ID tokens for you. A custom backend verifies the HS256 signature with the client secret and checks these claims, rejecting the request with 401 before calling the token endpoint when any check fails:

Claim Check

exp

The expiry time must be in the future.

nbf

The not-before time must be in the past.

aud

The audience must equal the app’s client ID.

iss and dest

Their hostnames must match.

sub

The user the token was issued for (read, not validated).

sid

A session ID, unique per user and app (read, not validated).

jti

A random UUID for the token (read, not validated).

iat

When the token was issued (read, not validated).

A decoded payload looks like this (all times are UNIX timestamps):

{
  "iss": "https://exampleshop.myshopify.com/admin",
  "dest": "https://exampleshop.myshopify.com",
  "aud": "client-id-123",
  "sub": "42",
  "exp": 1591765058,
  "nbf": 1591764998,
  "iat": 1591764998,
  "jti": "f8912129-1af6-4cad-9ca3-76b0f7621087",
  "sid": "aaea182f2732d44c23057c0fea584021a4485b2bd25d3eb7fd349313ad24c685"
}

A validator that runs

The module below implements exactly those checks with only node:crypto, using a constant-time signature comparison and refusing any algorithm other than HS256. It also contains a helper that signs a test token, so it can be exercised without Shopify. The client ID and secret are placeholders.

// id-token.mjs
import { createHmac, timingSafeEqual, randomUUID } from 'node:crypto';

const b64u = (buf) => Buffer.from(buf).toString('base64url');

export function validateIdToken(token, { clientId, clientSecret, now = Math.floor(Date.now() / 1000) }) {
  const parts = token.split('.');
  if (parts.length !== 3) throw new Error('not a JWT');
  const [h, p, s] = parts;
  const header = JSON.parse(Buffer.from(h, 'base64url').toString());
  if (header.alg !== 'HS256') throw new Error(`unexpected alg ${header.alg}`);
  const expected = createHmac('sha256', clientSecret).update(`${h}.${p}`).digest();
  const given = Buffer.from(s, 'base64url');
  if (given.length !== expected.length || !timingSafeEqual(given, expected)) throw new Error('bad signature');
  const c = JSON.parse(Buffer.from(p, 'base64url').toString());
  if (!(c.exp > now)) throw new Error('token expired (exp)');
  if (!(c.nbf <= now)) throw new Error('token not yet valid (nbf)');
  if (c.aud !== clientId) throw new Error('audience is not this app (aud)');
  if (new URL(c.iss).hostname !== new URL(c.dest).hostname) throw new Error('iss and dest hosts differ');
  return c;
}

// Test helper: signs a token shaped like the one App Bridge issues (placeholder secret only).
export function signIdToken(claims, secret) {
  const h = b64u(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
  const p = b64u(JSON.stringify(claims));
  const s = b64u(createHmac('sha256', secret).update(`${h}.${p}`).digest());
  return `${h}.${p}.${s}`;
}

export function sampleClaims(clientId, now = Math.floor(Date.now() / 1000)) {
  return {
    iss: 'https://pocket-coffee.myshopify.com/admin',
    dest: 'https://pocket-coffee.myshopify.com',
    aud: clientId,
    sub: '42',
    exp: now + 60,
    nbf: now - 5,
    iat: now - 5,
    jti: randomUUID(),
    sid: 'a'.repeat(64),
  };
}
// id-token-demo.mjs
import { validateIdToken, signIdToken, sampleClaims } from './id-token.mjs';

const clientId = '00000000000000000000000000000000';
const clientSecret = 'placeholder-client-secret-not-real';
const good = signIdToken(sampleClaims(clientId), clientSecret);
console.log('valid token      ->', validateIdToken(good, { clientId, clientSecret }).dest);

const now = Math.floor(Date.now() / 1000);
const cases = {
  'expired':        signIdToken({ ...sampleClaims(clientId), exp: now - 1 }, clientSecret),
  'wrong audience': signIdToken({ ...sampleClaims('11111111111111111111111111111111') }, clientSecret),
  'host mismatch':  signIdToken({ ...sampleClaims(clientId), dest: 'https://other.myshopify.com' }, clientSecret),
  'wrong secret':   signIdToken(sampleClaims(clientId), 'another-secret'),
};
for (const [name, tok] of Object.entries(cases)) {
  try { validateIdToken(tok, { clientId, clientSecret }); console.log(name, '-> accepted (unexpected)'); }
  catch (e) { console.log(name.padEnd(15), '->', 'rejected:', e.message); }
}

Output of node id-token-demo.mjs (Node 24):

valid token      -> https://pocket-coffee.myshopify.com
expired         -> rejected: token expired (exp)
wrong audience  -> rejected: audience is not this app (aud)
host mismatch   -> rejected: iss and dest hosts differ
wrong secret    -> rejected: bad signature

When a backend rejects a request with 401 and the request came from the app frontend over XHR or fetch, it adds the header X-Shopify-Retry-Invalid-Session-Request; App Bridge then intercepts the response, fetches a fresh ID token and retries the request once.

Token exchange

Token exchange is the grant embedded apps use: the backend swaps a valid ID token for an access token, with no redirect and no merchant interaction. It is an OAuth 2.0 token exchange request (RFC 8693) to the shop’s token endpoint, which is why it is described next to the generic token exchange and assertion grants material.

Request

Parameter Required Value

client_id

Yes

The app’s client ID.

client_secret

Yes

The app’s client secret.

grant_type

Yes

urn:ietf:params:oauth:grant-type:token-exchange

subject_token

Yes

The ID token that represents the merchant’s active session.

subject_token_type

Yes

urn:ietf:params:oauth:token-type:id_token

requested_token_type

No

urn:shopify:params:oauth:token-type:offline-access-token (default) or urn:shopify:params:oauth:token-type:online-access-token.

expiring

No

Only for offline tokens. 1 requests an expiring offline token; 0 (default) a non-expiring one.

An expired or otherwise invalid ID token makes the request fail with 400 Bad Request. Fetch a fresh ID token from App Bridge and retry; do not cache ID tokens.

curl -X POST "https://${SHOP}.myshopify.com/admin/oauth/access_token" \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \
  -d "subject_token=${ID_TOKEN}" \
  -d 'subject_token_type=urn:ietf:params:oauth:token-type:id_token' \
  -d "client_id=${SHOPIFY_CLIENT_ID}" \
  -d "client_secret=${SHOPIFY_CLIENT_SECRET}" \
  -d 'expiring=1'

Response

The exchange does not ask for scopes; the scope field reads back what is configured for the app on that store.

Field Meaning

access_token

The token for the X-Shopify-Access-Token header.

scope

Comma-separated scopes granted to the app on this store.

expires_in

Seconds until the access token expires (3600 for expiring offline tokens).

refresh_token

Present for expiring offline tokens only.

refresh_token_expires_in

Seconds until the refresh token expires (7776000, 90 days).

associated_user_scope

Online tokens only: the intersection of the app’s scopes and the staff member’s permissions.

associated_user

Online tokens only: the staff member (id, account_owner, collaborator, email_verified, email).

Store access_token and refresh_token on the backend together with absolute expiry times computed from expires_in and refresh_token_expires_in, so each can be refreshed before it lapses. Read expires_in from the response and do not hard-code durations: the values can change.

In the React Router template

The template hides all of this behind authenticate.admin(request). It validates the ID token, performs the exchange, stores the session and returns an authenticated admin client:

// app/routes/app._index.tsx
import type { LoaderFunctionArgs } from "react-router";
import { authenticate } from "../shopify.server";

export async function loader({ request }: LoaderFunctionArgs) {
  const { admin } = await authenticate.admin(request);

  const response = await admin.graphql(`
    query {
      shop {
        name
      }
    }
  `);

  return await response.json();
}

The configuration that makes the template use the grants described here is in app/shopify.server.ts. The public template (HEAD of 2026-10-09) pins the API version to October 2026, reads the scopes from the SCOPES environment variable, stores sessions with Prisma and enables expiring offline tokens:

// app/shopify.server.ts (excerpt of the public template)
const shopify = shopifyApp({
  apiKey: process.env.SHOPIFY_API_KEY,
  apiSecretKey: process.env.SHOPIFY_API_SECRET || "",
  apiVersion: ApiVersion.October26,
  scopes: process.env.SCOPES?.split(","),
  appUrl: process.env.SHOPIFY_APP_URL || "",
  authPathPrefix: "/auth",
  sessionStorage: new PrismaSessionStorage(prisma),
  distribution: AppDistribution.AppStore,
  future: {
    expiringOfflineAccessTokens: true,
  },
});

The template’s Prisma Session model has the columns the expiring flow needs: id, shop, state, isOnline, scope, expires, accessToken, userId, firstName, lastName, email, accountOwner, locale, collaborator, emailVerified, refreshToken and refreshTokenExpires. Its app/routes/auth.$.tsx route simply calls authenticate.admin(request) and returns, and app/routes/webhooks.app.uninstalled.tsx deletes the shop’s sessions when the app is uninstalled. Because the template stores accessToken, refreshToken and personal fields in plain columns, a production deployment encrypts the database at rest and restricts who can read the table.

A token endpoint client that runs

The module below implements the three calls of the token endpoint (exchange, refresh, client credentials) with fetch, converts a response into a record with absolute expiry times and decides when to refresh. The endpoint origin is a parameter, so the identical code can target the real shop domain or a local stand-in.

// token-client.mjs
const TOKEN_EXCHANGE = 'urn:ietf:params:oauth:grant-type:token-exchange';
const ID_TOKEN = 'urn:ietf:params:oauth:token-type:id_token';
const OFFLINE = 'urn:shopify:params:oauth:token-type:offline-access-token';
const ONLINE = 'urn:shopify:params:oauth:token-type:online-access-token';

async function post(origin, params) {
  const res = await fetch(`${origin}/admin/oauth/access_token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
    body: new URLSearchParams(params),
    signal: AbortSignal.timeout(30_000),
  });
  return { status: res.status, body: await res.json().catch(() => ({})) };
}

export function exchangeIdToken(origin, { clientId, clientSecret, idToken, online = false, expiring = true }) {
  const p = {
    client_id: clientId,
    client_secret: clientSecret,
    grant_type: TOKEN_EXCHANGE,
    subject_token: idToken,
    subject_token_type: ID_TOKEN,
    requested_token_type: online ? ONLINE : OFFLINE,
  };
  if (!online && expiring) p.expiring = '1';
  return post(origin, p);
}

export function refresh(origin, { clientId, clientSecret, refreshToken }) {
  return post(origin, {
    client_id: clientId,
    client_secret: clientSecret,
    grant_type: 'refresh_token',
    refresh_token: refreshToken,
  });
}

export function clientCredentials(origin, { clientId, clientSecret }) {
  return post(origin, { client_id: clientId, client_secret: clientSecret, grant_type: 'client_credentials' });
}

// Keep the pair and absolute expiry times; never log the token values.
export function toRecord(body, now = Date.now()) {
  return {
    accessToken: body.access_token,
    refreshToken: body.refresh_token ?? null,
    scope: body.scope,
    accessExpiresAt: body.expires_in > 0 ? now + body.expires_in * 1000 : null,
    refreshExpiresAt: body.refresh_token_expires_in > 0 ? now + body.refresh_token_expires_in * 1000 : null,
  };
}

// Returns a usable record, refreshing 60 seconds before expiry.
// 401 from the refresh endpoint is final: the merchant must reopen the app (token exchange).
export async function ensureFresh(origin, creds, record, now = Date.now()) {
  if (record.accessExpiresAt === null || record.accessExpiresAt - now > 60_000) return { record, action: 'none' };
  const r = await refresh(origin, { ...creds, refreshToken: record.refreshToken });
  if (r.status === 401) return { record: null, action: 're-authenticate' };
  if (r.status === 429 || r.status >= 500) return { record, action: 'retry-later' };
  if (r.status !== 200) throw new Error(`refresh failed: ${r.status}`);
  return { record: toRecord(r.body, now), action: 'refreshed' };
}

To run it without a store, the stand-in below listens on 127.0.0.1 on a random port and answers /admin/oauth/access_token with the documented response shapes. It reuses the ID token validator, so a bad ID token gets a 400. It is not Shopify and it is stricter than Shopify in one respect: it forgets a refresh token as soon as it is used, whereas Shopify keeps the previous one usable until the new one is used.

// mock-shopify.mjs
import http from 'node:http';
import { validateIdToken } from './id-token.mjs';

export function startMock({ clientId, clientSecret }) {
  let n = 0;
  const live = new Set(); // refresh tokens that are still usable
  const server = http.createServer(async (req, res) => {
    let raw = '';
    for await (const c of req) raw += c;
    const f = Object.fromEntries(new URLSearchParams(raw));
    const send = (code, obj) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
    if (req.url !== '/admin/oauth/access_token' || req.method !== 'POST') return send(404, {});
    if (f.client_id !== clientId || f.client_secret !== clientSecret) return send(401, { error: 'invalid_client' });
    const pair = () => { const rt = `refresh-${++n}`; live.add(rt); return {
      access_token: `access-${n}`, scope: 'read_products,write_products', expires_in: 3600,
      refresh_token: rt, refresh_token_expires_in: 7776000 }; };
    if (f.grant_type === 'urn:ietf:params:oauth:grant-type:token-exchange') {
      try { validateIdToken(f.subject_token, { clientId, clientSecret }); } catch { return send(400, { error: 'invalid_subject_token' }); }
      if (f.requested_token_type.endsWith('online-access-token')) {
        return send(200, { access_token: `online-${++n}`, scope: 'read_products,write_products', expires_in: 86399,
          associated_user_scope: 'read_products', associated_user: { id: 42, account_owner: false, email_verified: true } });
      }
      return f.expiring === '1' ? send(200, pair())
        : send(200, { access_token: `legacy-${++n}`, scope: 'read_products,write_products' });
    }
    if (f.grant_type === 'refresh_token') {
      if (!live.delete(f.refresh_token)) return send(401, { error: 'invalid_request', error_description: 'This request requires an active refresh_token' });
      return send(200, pair());
    }
    if (f.grant_type === 'client_credentials') return send(200, { access_token: `cc-${++n}`, scope: 'read_products', expires_in: 86399 });
    send(400, { error: 'unsupported_grant_type' });
  });
  return new Promise((r) => server.listen(0, '127.0.0.1', () => r({ server, origin: `http://127.0.0.1:${server.address().port}` })));
}
// token-demo.mjs
import { startMock } from './mock-shopify.mjs';
import { signIdToken, sampleClaims } from './id-token.mjs';
import { exchangeIdToken, refresh, clientCredentials, toRecord, ensureFresh } from './token-client.mjs';

const creds = { clientId: '00000000000000000000000000000000', clientSecret: 'placeholder-client-secret-not-real' };
const { server, origin } = await startMock(creds);
const idToken = signIdToken(sampleClaims(creds.clientId), creds.clientSecret);
const show = (label, r) => console.log(label.padEnd(34), r.status, Object.keys(r.body).join(','));

const off = await exchangeIdToken(origin, { ...creds, idToken });
show('exchange offline expiring=1', off);
show('exchange online', await exchangeIdToken(origin, { ...creds, idToken, online: true }));
show('exchange offline expiring=0', await exchangeIdToken(origin, { ...creds, idToken, expiring: false }));
show('exchange with bad ID token', await exchangeIdToken(origin, { ...creds, idToken: 'x.y.z' }));
show('client credentials', await clientCredentials(origin, creds));

const rec = toRecord(off.body);
const t0 = Date.now();
console.log('fresh record, 1 h left       ->', (await ensureFresh(origin, creds, rec, t0)).action);
const late = t0 + 3600_000 - 30_000; // 30 s before expiry
console.log('30 s before expiry           ->', (await ensureFresh(origin, creds, rec, late)).action);
const bogus = { ...rec, refreshToken: 'refresh-that-was-revoked' };
const dead = await refresh(origin, { ...creds, refreshToken: bogus.refreshToken });
console.log('unknown or revoked refresh   ->', dead.status, dead.body.error);
console.log('ensureFresh on that record   ->', (await ensureFresh(origin, creds, bogus, late)).action);
server.close();

Output of node token-demo.mjs:

exchange offline expiring=1        200 access_token,scope,expires_in,refresh_token,refresh_token_expires_in
exchange online                    200 access_token,scope,expires_in,associated_user_scope,associated_user
exchange offline expiring=0        200 access_token,scope
exchange with bad ID token         400 error
client credentials                 200 access_token,scope,expires_in
fresh record, 1 h left       -> none
30 s before expiry           -> refreshed
unknown or revoked refresh   -> 401 invalid_request
ensureFresh on that record   -> re-authenticate

Expiring offline access tokens

An offline access token persists beyond any merchant session, so it is what webhook handlers, scheduled jobs and background workers use. Until December 2025 it never expired. Since the 2025-12-10 change, an offline token can expire after one hour and comes with a refresh token that the app uses to get a new one without a user. A leaked expiring token stops working by itself, which is the reason Shopify is making them mandatory for public apps.

Lifetimes

Token Lifetime Renewed by

Expiring offline access token

1 hour (expires_in 3600)

Refresh token, or a new token exchange

Refresh token for an expiring offline token

90 days (refresh_token_expires_in 7776000) when issued; using it can shorten the previous one (see below)

Every refresh returns a new refresh token

Non-expiring offline access token

No expiry until the app is uninstalled or the client secret is revoked

Not applicable

Online access token

24 hours or until the user logs out of the admin, whichever comes first

Re-run token exchange with the current ID token (no refresh token)

Client credentials token

24 hours (expires_in 86399)

Repeat the same request (no refresh token)

Delegate access token

Inherits the parent’s remaining lifetime or the shorter expiresIn requested

Create a new one from the parent

Horizontal bars on a logarithmic time axis comparing the lifetimes of the ID token, expiring offline access token, refresh token, online token, client credentials token, non-expiring offline token and delegate token

Refresh and rotation

To refresh, send POST https://<shop>.myshopify.com/admin/oauth/access_token with client_id, client_secret, grant_type=refresh_token and the stored refresh_token. The behaviour that matters in production:

  • Every refresh returns a new access token and a new refresh token, each with a fresh 90-day expiry. Store both; the next refresh uses the new refresh token.

  • The previous access token stays valid until its own expires_in elapses, but new requests should use the new one.

  • The refresh token that was presented stays usable until the app uses the one issued in its place, so a lost response does not strand the store. That grace period ends 30 days after the token’s first use at the latest, and never runs past its original 90-day expiry. A 2026-08-28 change made refreshes more resilient to ambiguous failures: after a timeout, a network error or a transient 5xx, retry with the refresh token that is still held.

  • Shopify keeps one current expiring offline token per app and store. Acquiring a new one (by token exchange or the authorization code grant) retires older ones, which stay valid only until their own expires_in ends so in-flight requests can finish.

  • Do not refresh and acquire at the same time for one store: each retires the other’s result, so one of the two tokens arrives already invalid. Refresh one store at a time, and never let two workers refresh the same store.

  • When the refresh token can no longer be used (unknown, already replaced, expired, or the app was uninstalled or revoked) Shopify returns 401 Unauthorized with {"error": "invalid_request"} and the description This request requires an active refresh_token. The response is the same for every terminal case, so do not branch on the reason. Stop retrying and re-authenticate: run token exchange the next time a merchant opens the app.

  • 429 and 5xx answers are transient and safe to retry with the same refresh token.

The background refresh path is short:

sequenceDiagram autonumber participant J as Webhook handler or job participant D as Token store participant T as Token endpoint participant G as Admin GraphQL API J->>D: Load record for the shop alt access token valid for more than 60 seconds D-->>J: access token else about to expire J->>T: POST grant_type refresh_token alt 200 T-->>J: new access token and new refresh token J->>D: Replace the pair (one writer per shop) else 401 invalid_request T-->>J: refresh token cannot be used J->>D: Mark shop as needing re-authentication else 429 or 5xx T-->>J: transient failure J->>J: Retry later with the same refresh token end end J->>G: GraphQL with the current access token

The deadline for public apps

Expiring offline tokens are required for new public apps since 2026-04-01 and for all public apps from 2027-01-01. After that date Shopify rejects a GraphQL Admin API request that presents a non-expiring offline token with 403 Forbidden, and the errors value contains the phrase Non-expiring access tokens are no longer accepted for the Admin API followed by a migration link. A missing scope also answers 403, so match on the phrase before deciding to re-authenticate. The rule applies to GraphQL Admin API requests only, and not to custom apps or apps merchants create for themselves. Merchants do not have to reinstall: apps swap tokens in code.

To start receiving expiring tokens:

  • With the React Router (or Remix) app template, enable future: { expiringOfflineAccessTokens: true } in shopifyApp() and redeploy. The template stores and refreshes the pair automatically. Newly scaffolded apps already have the flag.

  • With token exchange done by hand, add expiring=1 to the request.

  • With the authorization code grant, add expiring=1 to the request that exchanges the code.

To migrate installations whose users do not open the app soon, and for background-only apps (webhook consumers, app proxy backends, Flow actions), exchange the stored non-expiring token directly. This request needs no ID token or user session:

curl -X POST "https://${SHOP}.myshopify.com/admin/oauth/access_token" \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \
  -d "client_id=${SHOPIFY_CLIENT_ID}" \
  -d "client_secret=${SHOPIFY_CLIENT_SECRET}" \
  -d "subject_token=${NON_EXPIRING_OFFLINE_TOKEN}" \
  -d 'subject_token_type=urn:shopify:params:oauth:token-type:offline-access-token' \
  -d 'requested_token_type=urn:shopify:params:oauth:token-type:offline-access-token' \
  -d 'expiring=1'

Rules of the migration request: subject_token must be an active non-expiring offline token that belongs to the app and store; online, delegate, scope-restricted and already-expiring tokens are rejected; both requested_token_type and expiring=1 are required. A parameter that breaks these rules gives 400 Bad Request with invalid_subject_token or invalid_requested_token_type. The old token is permanently invalid for the Admin API afterwards, so persist the new pair immediately. If the response is lost, repeat the same request with the original token and valid client credentials within seven days of the first exchange: an eligible retry returns the same pair (the access token’s expiry may be extended, the refresh token’s is not). Recovery stops after the issued pair is refreshed or a later acquisition for the store replaces it; if a retry returns invalid_subject_token, acquire a new token by ID token exchange.

Online access tokens and per-user permissions

An online access token is tied to one staff member and lives as long as that user’s admin web session, at most 24 hours. Use it for interactive work where the request must respect that person’s permissions and be attributed to them. A logout from the admin revokes every online token minted in that web session, and a user can revoke their own access to the app without affecting anyone else’s tokens.

Request one by exchanging the ID token with requested_token_type set to the online type, or, with the template, by setting useOnlineTokens: true in shopifyApp(); the template then manages both token kinds:

// app/routes/app.orders.tsx
export async function loader({ request }: LoaderFunctionArgs) {
  const { admin, session } = await authenticate.admin(request);
  const { associated_user, associated_user_scope } = session.onlineAccessInfo;

  if (!associated_user_scope.includes("write_orders")) {
    throw new Response("Forbidden", { status: 403 });
  }

  const response = await admin.graphql(`
    query {
      shop {
        name
      }
    }
  `);
  const { data } = await response.json();

  return { shopName: data.shop.name, staffId: associated_user.id };
}

The details to design around:

  • associated_user_scope is the intersection of the app’s scopes and that user’s permissions. A scope the staff member lacks does not appear, even when the app holds it.

  • A valid online token used for something the user may not do gives a 200 GraphQL response whose errors[n].extensions.code is ACCESS_DENIED; after expiry the answer is 401 Unauthorized.

  • associated_user carries id (key per-user data on it), account_owner, collaborator and email_verified. The email field is always present, but trust it only when email_verified is true.

  • Installation is stricter than later use: when an app that requests online access is not yet installed, the user who installs it must hold every scope the app requires, or the installation fails.

  • If the app caches data, scope the cache per user.

  • Once an app needs per-user authorization, a missing online token is a condition to recover from by running the exchange again, never a reason to fall back to the shop-wide offline token: that would run a low-privileged staff member’s request with the app’s full access.

  • shopify.app.toml can enable Direct API access (embedded_app_direct_api_access = true), in which the app frontend calls the Admin API directly with fetch('shopify:admin/api/2026-10/graphql.json') and App Bridge authenticates each request. direct_api_mode decides whether that uses an online (default) or offline token.

Client credentials grant

When an app acts only on stores in the developer’s own organization (a back-office integration for the Pocket Coffee stores, for instance), the app authenticates as itself. There is no merchant approval and no install redirect; the scopes are the ones selected on the app version in the Dev Dashboard and approved on each store after the version is released.

POST https://<shop>.myshopify.com/admin/oauth/access_token with client_id, client_secret and grant_type=client_credentials. Shopify ignores any scope parameter: the token carries all the scopes the store granted to the app installation, which can be broader than what the request names, and the response’s scope field reports them. The token is valid for 24 hours (expires_in 86399) and has no refresh token; to renew it, send the same request again. Keep the credentials in environment variables or a secret manager, never in a repository.

curl -X POST "https://${SHOP}.myshopify.com/admin/oauth/access_token" \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d 'grant_type=client_credentials' \
  -d "client_id=${SHOPIFY_CLIENT_ID}" \
  -d "client_secret=${SHOPIFY_CLIENT_SECRET}"

Authorization code grant for non-embedded apps

An app that runs outside the admin cannot use App Bridge, so it uses the classic redirect flow: send the merchant to the authorization URL with client_id, scope, redirect_uri and a state value, verify the HMAC and state on the callback, and exchange the returned code at the token endpoint with client_id, client_secret, code and optionally expiring=1. To get an online token instead of an offline one, append grant_options[]=per-user to the authorization URL; one authorization request returns one token type. A CLI-built embedded app does not implement any of this. The flow follows the generic OAuth authorization code pattern, and the token handling follows the access and refresh token practices; the Shopify-specific points are the shop-domain validation (accept only hostnames ending in .myshopify.com), the HMAC check of the callback query string and the expiring parameter.

Delegate access tokens

A delegate access token derives from the app’s offline token and carries only the scopes asked for. Use it when the app is split into server-side subsystems, so a job that processes orders holds no read access to products. It is a secret like any other token: never embed it in a mobile or desktop client where it could be extracted and replayed. It is sent in X-Shopify-Access-Token for the Admin API, or as a private token in Shopify-Storefront-Private-Token for the Storefront API. The mutation validates against the Admin 2026-10 schema (DelegateAccessTokenInput has the fields delegateAccessScope and expiresIn):

mutation DelegateForOrderJob {
  delegateAccessTokenCreate(input: { delegateAccessScope: ["read_orders"], expiresIn: 3600 }) {
    delegateAccessToken {
      accessToken
      accessScopes
      expiresIn
    }
    userErrors {
      field
      message
    }
  }
}

Access scopes

An access scope is a named unit of access that a merchant grants, such as read_products or write_orders. The scopes on a token decide what it can read and write, and each field and mutation in the Admin API reference names the scope it needs. Scopes fall into three families: authenticated scopes for the Admin API, unauthenticated scopes (prefix unauthenticated_) for the Storefront API, and customer scopes (prefix customer_) for the Customer Account API.

Rules that apply to every scope

  • A write_ scope includes the matching read_ scope. write_products grants read_products, so declare the write scope alone when the app needs both.

  • read_orders and write_orders cover orders created in the last 60 days. read_all_orders extends whichever order scope the app has to older orders, and does nothing on its own. With write_orders it also lets the app update older orders; write access always comes from write_orders.

  • Some scopes need Shopify’s approval before they can be declared (labelled "Shopify approval required" in the reference). Declaring one before approval makes creating the app version fail with an app_access validation error on scopes. read_all_orders is requested in the Partner Dashboard (API access requests, Read all orders scope); apps created in the Dev Dashboard under a merchant organization for stores of that organization do not need approval for it. Subscription scopes (read_customer_payment_methods, read_own_subscription_contracts, write_own_subscription_contracts) and the Shopify Payments dispute evidence scopes have their own request paths; write_payment_gateways and write_payment_sessions are granted automatically through a payments extension and should be left out of the first deployment.

  • More scopes reach protected customer data than the customer scopes: orders, draft orders, fulfillments, abandoned checkouts and store comments all carry customer data. Declaring them is allowed; outside development stores the API redacts protected fields until the app is approved (see Protected customer data).

  • Only ask for what the app uses. The App Store requirements ask for only the necessary scopes (requirement 3.2), and an unnecessary scope widens what a leaked token exposes.

  • Declaring a scope is not the same as having it. A scope takes effect only after it is approved on the store, and optional scopes are granted separately, so the granted set can differ from the configured one.

The scopes the Pocket Coffee app needs

Scope Pocket Coffee use Notes

write_products (includes read_products)

Read the 18-variant bean product, write the roast date metafield used by the badge

write_products already includes read_products, so only the write scope is declared.

read_inventory

Per-variant stock by location

write_inventory only if the app adjusts stock.

read_orders

Webhook handler for the "order placed" log

Last 60 days only; the order webhooks and the Order object carry protected customer data.

read_locales

Spanish and English translations

write_locales if the app publishes translations.

read_customers (optional)

Read Coffee Club members

Optional because only stores that use the club feature need it; protected customer data review applies.

read_all_orders (optional)

Year-on-year reporting

Extends read_orders; Shopify approval is required for apps under a Partner organization.

Required and optional scopes

scopes are granted at install and are guaranteed to be present afterwards. optional_scopes are requested after installation, if the app needs them, and the merchant can grant or decline them and can revoke them later. Optional scopes let one app serve stores with different needs (a store without the Coffee Club never gets asked for customer data). Rules for changing them after the app is live:

  • Changing scopes: merchants approve additions when they next open the app; removals apply automatically with no prompt. app/scopes_update fires when the grant changes.

  • Changing optional_scopes: the app can start requesting the new scopes, but the installation’s granted scopes do not change until the app requests them and the merchant agrees.

  • Moving a scope from scopes to optional_scopes: stores that already granted it keep it (retained as an approved optional scope, no re-prompt); new installs do not get it until the app requests it.

  • A required scope cannot imply an optional one: declaring read_products as optional while write_products is required fails to deploy with Declared optional_scopes [read_products] cannot be implicit required scopes. Remove the implied scope from optional_scopes, or move the write scope too.

  • Only scopes configured as optional_scopes can be requested or revoked dynamically.

Query what is granted

Query the installation, not the configuration:

query GrantedScopes {
  currentAppInstallation {
    accessScopes {
      handle
      description
    }
  }
}

The libraries wrap it: shopify.scopes.query() in App Bridge and scopes.query() in the React Router package.

Request more scopes dynamically

With the template, authenticate.admin(request) returns a scopes helper. scopes.request() performs a full-page redirect to the request URL when the scopes are not already granted; the App Bridge method shopify.scopes.request() opens a permission modal over the running app without redirecting.

// server side (React Router template)
const { scopes } = await authenticate.admin(request);
await scopes.request(['read_customers']);

// client side (App Bridge)
shopify.scopes.request(['read_customers']);

Without a library, send the merchant to the install URL, where optional_scopes must be a subset of the declared optional scopes:

https://admin.shopify.com/store/<store-name>/oauth/install?client_id=<client-id>&optional_scopes=read_customers

Revoke scopes the app no longer needs

Revoking narrows what a leaked token can do. Only optional scopes that were dynamically granted can be revoked:

mutation RevokeScopes {
  appRevokeAccessScopes(scopes: ["read_customers"]) {
    revoked {
      handle
    }
    userErrors {
      field
      message
    }
  }
}

Revoking a write scope also removes its read access when the read scope was granted only through the write scope. From Admin API version 2027-01 (a release candidate at the time of writing, scheduled for 2027-01-01) apps that use managed installation can call appDowngradeAccessScopes to replace optional write scopes by their matching read scopes instead; it is not present in the 2026-10 schema.

The three GraphQL documents above (currentAppInstallation, appRevokeAccessScopes, delegateAccessTokenCreate) and the customer query in the next section were validated against the downloaded Admin 2026-10 schema; a document with the field name accessScope instead of accessScopes is rejected with Cannot query field "accessScope" on type "AppInstallation". Did you mean "accessScopes"?

A small helper keeps the check honest in tests: it applies the "write implies read" rule when comparing what the configuration declares with what an installation reports.

// granted-scopes.mjs
const declared = { required: 'write_products,read_inventory,read_orders,read_locales'.split(','), optional: ['read_customers', 'read_all_orders'] };
const implied = (h) => (h.startsWith('write_') ? ['write_' + h.slice(6), 'read_' + h.slice(6)] : [h]);
export function expand(handles) { return new Set(handles.flatMap(implied)); }
export function missing(granted, wanted) { const g = expand(granted); return wanted.filter((h) => !g.has(h)); }
const grantedFromApi = ['write_products', 'read_inventory', 'read_orders', 'read_locales']; // sample currentAppInstallation result
console.log('required but not granted:', missing(grantedFromApi, declared.required));
console.log('optional to request:', missing(grantedFromApi, declared.optional));
console.log('implicit read of write_products present:', expand(grantedFromApi).has('read_products'));

Output:

required but not granted: []
optional to request: [ 'read_customers', 'read_all_orders' ]
implicit read of write_products present: true

Protected customer data

Shopify treats data that relates to a single customer as protected: names, addresses, emails and phone numbers, and anything that can be tied to one customer, such as orders. A public app that uses the Admin API or the Customer Account API needs a review of its use of that data; a custom app has the access automatically.

Levels and who needs the review

Level Data use What the partner does

0

No customer data

Nothing.

1

Customer data excluding name, address, phone and email fields

Request access to protected customer data in the Partner Dashboard and meet the level 1 requirements.

2

Customer data including name, address, phone or email fields

Request access to the data and to each field, meet the level 1 and 2 requirements and take part in data protection reviews.

Level Public app Custom app Admin-created custom app

1

Requires review

Always available

Always available

2

Requires review

Always available

Varies by plan

Apps installed only on development stores do not need a review: the data and fields are selected in the Partner Dashboard and are available in development. The approval is for the minimum data needed to provide the app’s functionality. A distribution method has to be selected before access can be requested.

The resources that count as protected include customers (name, addresses, email, phone), orders, draft orders, abandoned checkouts, refunds and transactions, shipping rates tied to an order, fulfillments, gift cards used by one customer, webhooks and metafields that relate to a single customer or order, checkout and payments in the Storefront API, and store comments. Types that do not refer to a single customer, such as the product query, are not included.

What the API returns before and after approval

After approval, responses contain only the approved fields and everything else is redacted. A GraphQL request to an unapproved type answers with HTTP 200 and an errors entry; the redacted field is null:

query CustomerContact($id: ID!) {
  customer(id: $id) {
    id
    email
    firstName
    lastName
    phone
  }
}

For an app approved for email and name but not phone, the documented behaviour is that email, firstName and lastName come back populated, phone is null, and an errors array carries a message of the form "This app is not approved to access the Customer object" with the path of the redacted field. Handle that by treating protected fields as optional in code and by not failing a whole page when one of them is missing. Web pixels follow the same rule since 2025-12-10: personal fields in the payload (customer name, email, phone, address) are set to null unless the app is approved for the matching protected scopes (read_customer_name, read_customer_email, read_customer_phone, read_customer_address, read_customer_personal_data); custom pixels are out of scope.

Requesting access

In the Partner Dashboard, open the app, choose API access requests, find Protected customer data access and request it. Select the data, give the reasons, select the protected fields the app needs one by one (name, address, email, phone, each with its reasons) and complete the data protection details. Access to the data is then reviewed. In development you can use the data once the fields are selected.

Requirements the review checks

Level 1: process only the minimum personal data needed for the stated purpose; tell merchants what is processed and why (privacy policy or data protection agreement); limit processing to the stated purposes; respect customer consent and opt-out decisions (including data sale opt-outs and automated decision-making that has legal or significant effects); make privacy and data protection agreements with merchants; apply retention periods; and encrypt data at rest and in transit.

Level 2 adds: encrypt data backups; keep test and production data separate; have a data loss prevention strategy; limit staff access to protected customer data; require strong passwords for staff accounts; keep an access log of access to the data; and have a security incident response policy.

Shopify may select an app for a detailed data protection review, which asks for evidence that the practices meet the requirements. Reviews are most likely for apps with many installs, a high volume of customer records, more approved protected fields or long retention of personal data. The requirements do not replace the Partner Program Agreement and the API terms the partner has already accepted, whatever API version is used.

Compliance webhooks

Public apps must also subscribe to three mandatory compliance topics, declared in the TOML file with compliance_topics (the template ships them commented out under "Mandatory compliance topic for public apps only"): customers/data_request, customers/redact and shop/redact. The endpoint must accept POST with a JSON body, verify the HMAC (answer 401 Unauthorized when it is invalid), respond with a 2xx status and complete the requested action within 30 days unless the law requires retention. The handlers and the HMAC verification are shown in the webhooks page; the example TOML above already declares all three topics.

Pocket Coffee: putting it together

The companion app of the Pocket Coffee shop is an embedded public app on the React Router template. It declares the four required scopes and two optional ones from the table above; installs through managed installation; keeps an expiring offline token and its refresh token per shop in the template’s Prisma Session table (encrypted database, one writer per shop); enables online tokens only for the admin screen that shows staff-specific data; and logs "order placed" from a webhook handler that loads the offline session for the shop, refreshes it when the access token is about to expire, queries the order with read_orders and records the order number and total. Because the order carries customer fields, the log stores the order name and totals only, and the customer email, name and address are not copied into the log. The Coffee Club feature is offered only to stores that grant read_customers, requested from a button in the app with shopify.scopes.request(['read_customers']) and revoked with appRevokeAccessScopes when the club is switched off.

Errors and pitfalls

Symptom Cause Fix

400 Bad Request from the token endpoint during exchange

ID token expired, malformed, wrong audience or from another app

Fetch a fresh ID token from App Bridge and retry once; check the client ID and secret match the deployed app.

401 on a GraphQL call

Access token expired, revoked or the app was uninstalled

Refresh (offline) or re-exchange (online); if refresh also gives 401, wait for the merchant to reopen the app.

403 with Non-expiring access tokens are no longer accepted

Public app still using a non-expiring offline token after the deadline

Migrate to expiring tokens; match on the phrase because a missing scope is also 403.

200 with ACCESS_DENIED in errors[n].extensions.code

Online token whose user lacks permission for that field

Show a permission message; do not fall back to the offline token.

200 with null personal fields and an error saying the app is not approved

Protected customer data not approved (or field not requested)

Request access, or handle the redaction.

A scope appears in shopify.app.toml but calls fail

Not yet approved on the store, or an optional scope never requested

Query currentAppInstallation { accessScopes }; request the optional scope.

Deploy fails with Declared optional_scopes [...] cannot be implicit required scopes.

An optional read scope is implied by a required write scope

Remove it from optional_scopes.

app_access validation error on scopes

A scope that needs Shopify’s approval was declared before approval

Request approval first, then declare it.

Two workers hold tokens that stop working

Refresh and acquire (or two refreshes) ran concurrently for one store

Serialise per shop; refresh from the token store, not from memory.

Embedded app loses its session in the browser

Reliance on third-party cookies

Use ID tokens and token exchange only.

Compliance webhook rejected

Invalid HMAC verification or wrong status code

Verify the HMAC on the raw body; answer 401 on mismatch and 2xx otherwise.

Security practice for tokens

  • Keep the client secret on the server and read it from the environment or a secret manager. Rotating it invalidates tokens tied to the old secret, so every store’s token has to be re-acquired; treat rotation as a planned operation.

  • Store access tokens and refresh tokens encrypted, keyed by shop (and user for online tokens), and restrict who can read the table. Never log token values; log the shop, the grant and the outcome.

  • Never send the access token or the ID token to the browser or a third-party service. The ID token travels from the browser to the app’s own backend only.

  • Prefer the shortest useful token: online for interactive work, delegate tokens for subsystems, expiring offline tokens for background work.

  • Scan repositories for shpat_ and shppa_ prefixes; use obvious placeholders (${SHOPIFY_ACCESS_TOKEN}) in examples and documentation.

  • Handle uninstall: on app/uninstalled, delete the shop’s sessions and stop jobs that would use them.

What was run and what was not

Run locally (macOS, Node 24, Shopify CLI 5.0.0, no login, no network access to a store):

  • shopify app build --no-input on the shopify.app.toml shown above (success), and on variants with a wrong type for scopes and optional_scopes (validation errors quoted above).

  • The four GraphQL documents against the Admin API 2026-10 schema downloaded anonymously through Shopify’s public introspection proxy (valid), plus one misspelled document (rejected).

  • node id-token-demo.mjs, node token-demo.mjs and node granted-scopes.mjs with the code shown, against a local stand-in server on 127.0.0.1.

Not run, because they need a Shopify account, a store or the Partner or Dev Dashboard: installing an app, the real token endpoint (exchange, refresh, migration, client credentials), shopify app deploy, dynamic scope requests and revocations, Direct API access, the protected customer data request and review, and the redaction behaviour of a real store. Those parts are described from Shopify’s documentation as read on 2026-10-27, and the stand-in server is not evidence of Shopify’s behaviour. The token values, client IDs and secrets in the examples are placeholders.

Timeline

Date Change

2024-12-04

Optional scopes configurable in the app configuration.

2024-12-17

Session tokens and listings are checked automatically before submitting an app for review.

2025-12-10

Offline access tokens can expire after 60 minutes and come with a refresh token. Web pixel payloads start filtering protected personal fields.

2026-04-01

Expiring offline tokens required for new public apps.

2026-08-28

More resilient refreshes for expiring offline tokens.

2026-09-29

A lost migration response can be recovered for seven days.

2026-10-07

Announced: appDowngradeAccessScopes, available from Admin API 2027-01.

2027-01-01

Expiring offline tokens required for all public apps; non-expiring tokens are rejected on Admin API requests.

The generic OAuth 2.0 and JWT background is in OAuth getting started and JWT and JOSE. For comparison, WooCommerce, PrestaShop and Magento authenticate integrations with application passwords, webservice keys and integration tokens; those platforms keep their own pages.