---
title: Authenticate an embedded app without a template
description: >-
  Implement token exchange for embedded apps that don't use a Shopify app
  template.
source_url:
  html: >-
    https://shopify.dev/docs/apps/build/authentication-authorization/implement-token-exchange?lang=node
  md: >-
    https://shopify.dev/docs/apps/build/authentication-authorization/implement-token-exchange.md?lang=node
---

# Authenticate an embedded app without a template

Embedded Shopify apps get access tokens through token exchange: [App Bridge](https://shopify.dev/docs/api/app-home) provides a short-lived ID token proving the merchant's current session, and your backend exchanges it with Shopify for an access token to call Shopify APIs. For the endpoint and parameters behind this flow, see the [access tokens reference](https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens#get-an-access-token).

When you scaffold your app with [Shopify CLI](https://shopify.dev/docs/apps/build/cli-for-apps), the [Shopify app template](https://shopify.dev/docs/api/libraries-and-templates#app-templates) handles this automatically. Use this tutorial if you're building an embedded app with a custom frontend that doesn't call [`authenticate.admin()`](https://shopify.dev/docs/apps/build/authentication-authorization/cli-app-authentication#authenticate-requests-in-your-routes), or if you need to understand what the template does under the hood.

**Info:**

For server-side integrations acting on stores in your own Shopify organization, use the [client credentials grant](https://shopify.dev/docs/apps/build/authentication-authorization/client-credentials-grant) instead. To learn how authentication works for other common approaches, see [About app authentication](https://shopify.dev/docs/apps/build/authentication-authorization).

## What you'll learn

In this tutorial, you'll learn how to do the following tasks:

* Get an ID token from App Bridge
* Exchange it for an offline or online access token
* Make authenticated GraphQL Admin API requests
* Refresh an expiring offline token

Your backend language selection applies to the backend steps. The first step runs in the browser, so its samples are always JavaScript.

## Requirements

[App credentials](https://shopify.dev/docs/apps/build/authentication-authorization/manage-credentials)

You have your app's client ID and client secret from the Dev Dashboard.

[Access scopes](https://shopify.dev/docs/apps/build/authentication-authorization/manage-access-scopes)

You've configured the access scopes your app needs.

[Dev store](https://shopify.dev/docs/apps/build/dev-dashboard/stores/development-stores#create-a-dev-store)

You've installed your app on a dev store, which grants the access scopes it requests.

[App Bridge](https://shopify.dev/docs/api/app-home/apis#adding-app-bridge-to-your-app)

Your frontend loads App Bridge from the CDN script tag, so the `shopify` global is available to call.

## Project

[View on GitHub](https://github.com/Shopify/example-auth--token-exchange)

## Get an ID token from App Bridge

An ID token is a short-lived JWT that Shopify generates when a merchant opens your app. It proves the request is from an authenticated Shopify user. App Bridge generates a fresh token for each session and your frontend sends it to your backend on every request.

### Call id​Token() in your frontend

The method you use depends on where your app is rendered:

* In [App Home](https://shopify.dev/docs/apps/build/app-home), your app's main page in the Shopify admin, call `shopify.idToken()`.
* In an [admin UI extension](https://shopify.dev/docs/apps/build/admin), which adds functionality to an admin resource page such as **Products** or **Orders**, call `auth.idToken()`.

ID tokens expire after one minute. Fetch a fresh one on each request rather than caching it.

## /public/app.js

```javascript
const idToken = await shopify.idToken();


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

### Send the token to your backend

Include the ID token in the `Authorization` header on requests from your frontend to your backend.

## /public/app.js

```javascript
const idToken = await shopify.idToken();


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

## Exchange the ID token for an access token

Your backend validates the ID token, then sends it to Shopify's token endpoint to receive an access token. Shopify supports two access token types. Offline access tokens are the default: they persist across sessions and aren't tied to a specific user, which suits background jobs, webhooks, and scheduled work. Online access tokens are tied to the staff member who opened your app and expire with their session, so use them when your app needs to enforce per-user permissions or attribute actions to a specific person. For a fuller comparison, see [Access token types](https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens#access-token-types).

### Validate the ID token

Validate the ID token before your app trusts it. The samples use a JWT library for this: `jsonwebtoken` for Node.js and `PyJWT` for Python. Check the signature against your client secret, then verify the following claims:

| Claim | What to check |
| - | - |
| `exp` | Must be in the future. |
| `nbf` | Must be in the past. |
| `aud` | Must match your app's client ID. |
| `iss` and `dest` | Hostnames must match. |

If any check fails, the library raises an error and the exchange routes return a `401` before reaching Shopify's token endpoint.

When you reject a request with a `401`, set the `X-Shopify-Retry-Invalid-Session-Request` header if the request came from your frontend over XHR or `fetch`. App Bridge intercepts the response, fetches a fresh ID token, and retries the request once.

## /node/index.js

```javascript
import 'dotenv/config';
import express from 'express';
import path from 'path';
import {readFileSync} from 'fs';
import {fileURLToPath} from 'url';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';


const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express();
app.use(express.json());


const {SHOPIFY_CLIENT_ID, SHOPIFY_CLIENT_SECRET, REFRESH_TASK_SECRET} =
  process.env;


// Inject the App Bridge API key (your client ID) into index.html before serving
// it. express.static would return the file verbatim, leaving the literal
// %SHOPIFY_API_KEY% placeholder in the page — so App Bridge never initializes.
app.get(['/', '/index.html'], (req, res) => {
  const html = readFileSync(
    path.join(__dirname, '..', 'public', 'index.html'),
    'utf8',
  ).replace('%SHOPIFY_API_KEY%', SHOPIFY_CLIENT_ID);
  res.type('html').send(html);
});


app.use(express.static(path.join(__dirname, '..', 'public')));


// In-memory token store (use a persistent session store in production)
const tokenStore = {};


// Shops whose Admin API calls must run under the acting staff member's own
// online token. A real app knows this statically — it's a property of how the
// app is built, not of what's in the store. This sample records it the first
// time /exchange/online succeeds so both flows stay demonstrable.
//
// The point of tracking it at all: once an app needs per-user authorization,
// a missing online token is a condition to recover from, never a reason to
// reach for the shop-wide offline token. That substitution would run a
// low-privileged staff member's request with the app's full access.
const perUserAuthorization = new Set();


// A valid expiring-token response includes expires_in (seconds until the access
// token expires). Return null when it's absent or non-positive: treat the token
// as non-expiring and never refresh it. Storing Date.now() instead would make
// the next request refresh a token that has no refresh_token — a permanent 401.
function expiresAtFrom(expiresIn) {
  const seconds = Number(expiresIn);
  return seconds > 0 ? Date.now() + seconds * 1000 : null;
}


// Node's fetch has no timeout, so a stalled connection to Shopify would hang a
// request until the client gives up. Give every call a deadline, and tag transport
// failures so callers can tell "Shopify said no" from "we never reached Shopify".
// fetch rejects only on a transport failure or this timeout: every HTTP status,
// including 5xx, resolves and is the caller's to handle.
const SHOPIFY_TIMEOUT_MS = 30_000;


class ShopifyUnreachable extends Error {}


async function shopifyFetch(url, options) {
  try {
    return await fetch(url, {
      ...options,
      signal: AbortSignal.timeout(SHOPIFY_TIMEOUT_MS),
    });
  } catch (cause) {
    throw new ShopifyUnreachable(`Could not reach ${new URL(url).hostname}`, {
      cause,
    });
  }
}


function validateIdToken(idToken) {
  const payload = jwt.verify(idToken, SHOPIFY_CLIENT_SECRET, {
    algorithms: ['HS256'],
    audience: SHOPIFY_CLIENT_ID,
  });


  const issuerHost = new URL(payload.iss).hostname;
  const destHost = new URL(payload.dest).hostname;
  if (issuerHost !== destHost) {
    throw new Error('Token issuer and destination do not match');
  }


  return payload;
}


// Background callers (webhooks, scheduled jobs) have no session to produce an
// ID token, so they authenticate with a shared secret that only your own
// backend and schedulers know. It's sent in the `X-Refresh-Secret` header.
function isAuthorizedTask(req) {
  const provided = req.get('X-Refresh-Secret') ?? '';
  if (!REFRESH_TASK_SECRET || !provided) return false;
  const a = Buffer.from(provided);
  const b = Buffer.from(REFRESH_TASK_SECRET);
  // timingSafeEqual throws on length mismatch, so check length first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}


// Exchange the stored offline refresh token for a new offline access token.
// The return value tells the caller how to react:
//   'refreshed'   — stored a new access token
//   'reauthorize' — no refresh token, or Shopify returned 401 (the refresh token
//                   is expired, revoked, replayed outside the retry window, or the
//                   app was uninstalled); the merchant must reinstall
//   'retry'       — a transient failure (network, 5xx, 429); safe to retry later
//   'failed'      — any other non-OK status, such as a malformed request or bad
//                   client credentials; the same request fails the same way, so
//                   surface it instead of hiding it behind the retry path
async function refreshOfflineToken(shop) {
  const stored = tokenStore[shop];
  if (!stored?.refresh_token) return 'reauthorize';


  let response;
  try {
    response = await shopifyFetch(`https://${shop}/admin/oauth/access_token`, {
      method: 'POST',
      headers: {'Content-Type': 'application/x-www-form-urlencoded'},
      body: new URLSearchParams({
        grant_type: 'refresh_token',
        client_id: SHOPIFY_CLIENT_ID,
        client_secret: SHOPIFY_CLIENT_SECRET,
        refresh_token: stored.refresh_token,
      }),
    });
  } catch (error) {
    // Only a transport failure or timeout becomes 'retry': the request never
    // reached Shopify, so the refresh token is untouched and a later attempt is
    // safe. Anything else is a bug in this code — let it surface.
    if (!(error instanceof ShopifyUnreachable)) throw error;
    return 'retry';
  }


  if (response.status === 401) {
    delete tokenStore[shop];
    return 'reauthorize';
  }
  // Only a rate limit or a server fault is worth retrying. Treating every other
  // non-OK status as transient would retry an unrecoverable refresh forever —
  // a 400 for a malformed body, or a 403 for bad client credentials, returns the
  // same response no matter how long you wait.
  if (response.status === 429 || response.status >= 500) return 'retry';
  if (!response.ok) return 'failed';


  const {access_token, refresh_token, expires_in} = await response.json();
  tokenStore[shop] = {
    access_token,
    refresh_token,
    expires_at: expiresAtFrom(expires_in),
  };
  return 'refreshed';
}


app.post('/exchange/offline', async (req, res) => {
  const idToken = req.headers.authorization?.replace('Bearer ', '');
  let payload;
  try {
    payload = validateIdToken(idToken);
  } catch {
    // Signal App Bridge to fetch a fresh ID token and retry this request once.
    res.set('X-Shopify-Retry-Invalid-Session-Request', '1');
    return res.status(401).json({error: 'Invalid ID token'});
  }


  const shop = new URL(payload.dest).hostname;


  const response = await shopifyFetch(
    `https://${shop}/admin/oauth/access_token`,
    {
      method: 'POST',
      headers: {'Content-Type': 'application/x-www-form-urlencoded'},
      body: new URLSearchParams({
        client_id: SHOPIFY_CLIENT_ID,
        client_secret: SHOPIFY_CLIENT_SECRET,
        grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
        subject_token: idToken,
        subject_token_type: 'urn:ietf:params:oauth:token-type:id_token',
        requested_token_type:
          'urn:shopify:params:oauth:token-type:offline-access-token',
        expiring: '1',
      }),
    },
  );


  // Shopify returns 400 when the ID token is expired or otherwise invalid. ID
  // tokens live about a minute, so that's a routine client condition, not a server
  // fault — answer it like a local validation failure so App Bridge fetches a fresh
  // token and retries. Returning 502 would say the opposite: don't bother retrying.
  if (response.status === 400) {
    res.set('X-Shopify-Retry-Invalid-Session-Request', '1');
    return res.status(401).json({error: 'Invalid ID token'});
  }


  if (!response.ok) {
    return res.status(502).json({error: 'Token exchange failed'});
  }


  const {access_token, refresh_token, scope, expires_in} = await response.json();


  // Store tokens server-side — never send them to the browser. Track expiry so
  // requests can refresh the offline token before it lapses.
  tokenStore[shop] = {
    access_token,
    refresh_token,
    expires_at: expiresAtFrom(expires_in),
  };


  res.json({scope});
});


app.post('/exchange/online', async (req, res) => {
  const idToken = req.headers.authorization?.replace('Bearer ', '');
  let payload;
  try {
    payload = validateIdToken(idToken);
  } catch {
    // Signal App Bridge to fetch a fresh ID token and retry this request once.
    res.set('X-Shopify-Retry-Invalid-Session-Request', '1');
    return res.status(401).json({error: 'Invalid ID token'});
  }


  const shop = new URL(payload.dest).hostname;


  const response = await shopifyFetch(
    `https://${shop}/admin/oauth/access_token`,
    {
      method: 'POST',
      headers: {'Content-Type': 'application/x-www-form-urlencoded'},
      body: new URLSearchParams({
        client_id: SHOPIFY_CLIENT_ID,
        client_secret: SHOPIFY_CLIENT_SECRET,
        grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
        subject_token: idToken,
        subject_token_type: 'urn:ietf:params:oauth:token-type:id_token',
        requested_token_type:
          'urn:shopify:params:oauth:token-type:online-access-token',
      }),
    },
  );


  // Same as the offline route: a 400 means the ID token is stale, which a fresh
  // one fixes. Don't dress a retryable condition up as a server error.
  if (response.status === 400) {
    res.set('X-Shopify-Retry-Invalid-Session-Request', '1');
    return res.status(401).json({error: 'Invalid ID token'});
  }


  if (!response.ok) {
    return res.status(502).json({error: 'Token exchange failed'});
  }


  const {access_token, scope, expires_in} = await response.json();


  // Online tokens are scoped to the staff member who authorized them, so key
  // them by user (the ID token's `sub`) — not just by shop. Storing under a
  // shop-only key would let one staff member's token overwrite another's.
  // Track expiry so a lapsed token is dropped rather than sent.
  tokenStore[`${shop}:online:${payload.sub}`] = {
    access_token,
    expires_at: expiresAtFrom(expires_in),
  };


  // This app authorizes Admin API calls per staff member for this shop from here
  // on. /api/shop reads this and mints a replacement online token when one is
  // missing, rather than borrowing the shop-wide offline token.
  perUserAuthorization.add(shop);


  res.json({scope});
});


// Re-run token exchange for a token Shopify rejected, using the ID token that
// came with the current request. Same request as /exchange/offline and
// /exchange/online, minting whichever kind was rejected. The return value tells
// the caller how to react:
//   'minted'           — stored a new access token
//   'invalid_id_token' — Shopify returned 400; the ID token is stale, and a fresh
//                        one fixes it
//   'failed'           — anything else; retrying with the same inputs won't help
async function remintAccessToken({idToken, shop, sub, online}) {
  const response = await shopifyFetch(
    `https://${shop}/admin/oauth/access_token`,
    {
      method: 'POST',
      headers: {'Content-Type': 'application/x-www-form-urlencoded'},
      body: new URLSearchParams({
        client_id: SHOPIFY_CLIENT_ID,
        client_secret: SHOPIFY_CLIENT_SECRET,
        grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
        subject_token: idToken,
        subject_token_type: 'urn:ietf:params:oauth:token-type:id_token',
        requested_token_type: online
          ? 'urn:shopify:params:oauth:token-type:online-access-token'
          : 'urn:shopify:params:oauth:token-type:offline-access-token',
        // `expiring` only applies to offline tokens. An online token already
        // expires with the staff member's session.
        ...(online ? {} : {expiring: '1'}),
      }),
    },
  );


  if (response.status === 400) return 'invalid_id_token';
  if (!response.ok) return 'failed';


  const {access_token, refresh_token, expires_in} = await response.json();


  if (online) {
    tokenStore[`${shop}:online:${sub}`] = {
      access_token,
      expires_at: expiresAtFrom(expires_in),
    };
  } else {
    tokenStore[shop] = {
      access_token,
      refresh_token,
      expires_at: expiresAtFrom(expires_in),
    };
  }


  return 'minted';
}


app.get('/api/shop', async (req, res) => {
  const idToken = req.headers.authorization?.replace('Bearer ', '');
  let payload;
  try {
    payload = validateIdToken(idToken);
  } catch {
    // Signal App Bridge to fetch a fresh ID token and retry this request once.
    res.set('X-Shopify-Retry-Invalid-Session-Request', '1');
    return res.status(401).json({error: 'Invalid ID token'});
  }


  const shop = new URL(payload.dest).hostname;
  const onlineKey = `${shop}:online:${payload.sub}`;


  // Drop an expired online token rather than sending a dead credential. Online
  // tokens can't be refreshed — a new one is minted from a fresh ID token.
  const online = tokenStore[onlineKey];
  if (online?.expires_at && online.expires_at <= Date.now()) {
    delete tokenStore[onlineKey];
  }


  // Which token authorizes this call is decided by how the app is built, not by
  // what happens to be in the store. Deciding it by availability instead — the
  // `tokenStore[onlineKey] ?? tokenStore[shop]` this used to do — means that
  // whenever a staff member's online token is missing or expired, and online
  // tokens expire at logout or after 24 hours, their request quietly goes out
  // under the app's shop-wide offline token with the app's full scopes. The
  // Admin API then enforces nothing about that user, so a staff member without
  // permission for an action gets it anyway.
  const usingOnline = perUserAuthorization.has(shop);


  // Per-user app, no usable online token for this staff member: mint one from
  // the ID token this request already carries. This is the recovery the fallback
  // was standing in for, and it costs one token exchange.
  if (usingOnline && !tokenStore[onlineKey]) {
    const result = await remintAccessToken({
      idToken,
      shop,
      sub: payload.sub,
      online: true,
    });


    if (result === 'invalid_id_token') {
      res.set('X-Shopify-Retry-Invalid-Session-Request', '1');
      return res.status(401).json({error: 'Invalid ID token'});
    }
    if (result !== 'minted') {
      return res.status(502).json({error: 'Token exchange failed'});
    }
  }


  // Only the offline token is refreshable (online tokens are re-minted from a
  // fresh ID token via /exchange/online). Refresh it ~60 seconds before it
  // expires — but only when we're about to use it, so a still-valid online token
  // isn't blocked by a failed offline refresh.
  if (!usingOnline) {
    const offline = tokenStore[shop];
    if (offline?.expires_at && Date.now() >= offline.expires_at - 60 * 1000) {
      const result = await refreshOfflineToken(shop);
      if (result === 'reauthorize') {
        // No retry header here: the offline refresh token is dead, and a fresh
        // ID token can't revive it. The client must re-run /exchange/offline
        // (or reinstall) rather than retry this request into another 401.
        return res.status(401).json({error: 'reauthenticate'});
      }
      if (result === 'retry') {
        return res.status(503).json({error: 'Token refresh failed, try again'});
      }
      if (result === 'failed') {
        // Not transient and not the merchant's problem. Don't fall through — the
        // stored token is about to expire, so the request below would go out with
        // a credential that's already lapsing.
        return res.status(502).json({error: 'Token refresh rejected'});
      }
    }
  }


  // No `??` here on purpose: each mode reads only its own token, so a missing
  // one is a 401 rather than a silent upgrade to broader access.
  const stored = usingOnline ? tokenStore[onlineKey] : tokenStore[shop];
  if (!stored) return res.status(401).json({error: 'Not authenticated'});


  const callAdminApi = (accessToken) =>
    shopifyFetch(`https://${shop}/admin/api/2026-04/graphql.json`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-Shopify-Access-Token': accessToken,
      },
      body: JSON.stringify({query: '{ shop { name } }'}),
    });


  let response = await callAdminApi(stored.access_token);


  // Shopify rejected the access token (revoked, or the app's scopes changed).
  // Evict it and mint a replacement with token exchange: this request already
  // carries a validated ID token, so nothing the merchant does is needed.
  //
  // Don't answer this with X-Shopify-Retry-Invalid-Session-Request. That header
  // only makes App Bridge fetch a fresh *ID* token and replay this request — it
  // never re-runs the exchange, so the replay would find no stored token and fail
  // with "Not authenticated". It's the right answer for a rejected ID token, and
  // the wrong one for a rejected access token.
  if (response.status === 401) {
    const evictKey = usingOnline ? onlineKey : shop;
    delete tokenStore[evictKey];


    const result = await remintAccessToken({
      idToken,
      shop,
      sub: payload.sub,
      online: usingOnline,
    });


    // Only now is the retry header correct: the ID token itself is stale, and a
    // fresh one lets the replayed request mint a token successfully.
    if (result === 'invalid_id_token') {
      res.set('X-Shopify-Retry-Invalid-Session-Request', '1');
      return res.status(401).json({error: 'Invalid ID token'});
    }
    if (result !== 'minted') {
      return res.status(502).json({error: 'Token exchange failed'});
    }


    response = await callAdminApi(tokenStore[evictKey].access_token);


    // Retry once, not in a loop. A freshly minted token that's also rejected
    // means something is wrong beyond a stale credential, so stop and tell the
    // merchant to reauthorize rather than mint tokens indefinitely.
    if (response.status === 401) {
      delete tokenStore[evictKey];
      return res.status(401).json({error: 'reauthenticate'});
    }
  }


  // Forward Shopify's status. Answering a rate limit or an outage with a 200 and
  // an error body in it would tell the client the request succeeded.
  res.status(response.status).json(await response.json());
});


app.post('/refresh', async (req, res) => {
  // This endpoint mints a new access token, so authenticate the caller first.
  // Background callers (webhooks, scheduled jobs) have no session to produce an
  // ID token, so they send a shared secret in the `X-Refresh-Secret` header.
  if (!isAuthorizedTask(req)) {
    return res.status(401).json({error: 'Unauthorized'});
  }


  // Background callers supply the shop domain directly, since they have no
  // active session to derive it from an ID token. Defaulting to {} keeps a
  // request with no body — or the wrong content type — on the documented 400
  // path: Express 5 leaves req.body undefined when nothing was parsed, so
  // destructuring it directly would throw and return 500.
  const {shop} = req.body ?? {};
  if (!shop) return res.status(400).json({error: 'Missing shop'});


  // A 401 is terminal (expired, revoked, replayed outside the retry window, or
  // the app was uninstalled): re-authenticate the next time a merchant opens the
  // app. Other failures (network, 5xx, 429) are transient and safe to retry.
  const result = await refreshOfflineToken(shop);
  if (result === 'reauthorize') {
    return res.status(401).json({error: 'reauthenticate'});
  }
  if (result === 'retry') {
    return res.status(502).json({error: 'Token refresh failed'});
  }
  if (result === 'failed') {
    // Not transient: retrying sends the identical request. Answering with
    // success here would tell the client it holds a fresh token when it doesn't.
    return res.status(502).json({error: 'Token refresh rejected'});
  }


  res.json({success: true});
});


// express.json() throws on a malformed body, and Express's default error handler
// answers with an HTML page containing a stack trace and absolute file paths. This
// is a JSON API, so answer in JSON. Match only parse failures: anything else should
// keep surfacing loudly rather than be swallowed here.
app.use((err, req, res, next) => {
  if (err?.type === 'entity.parse.failed') {
    return res.status(400).json({error: 'Malformed JSON body'});
  }
  // The request never reached Shopify, so nothing was consumed and the caller can
  // try again. 503 says that; the stack trace Express would otherwise return says
  // the app is broken.
  if (err instanceof ShopifyUnreachable) {
    return res.status(503).json({error: 'Could not reach Shopify, try again'});
  }
  next(err);
});


app.listen(3000, () => console.log('Server running on http://localhost:3000'));
```

### Request an offline access token

Offline access tokens persist across sessions. Use them for background jobs, webhooks, and anything that runs without an active merchant session. New public apps must use expiring offline access tokens. Include `expiring=1` in your request and Shopify returns a refresh token alongside your access token. If you're updating an existing public app that still requests non-expiring tokens, see [Migrate to expiring offline access tokens](https://shopify.dev/docs/apps/build/authentication-authorization/migrate-to-expiring-offline-access-tokens).

If the ID token is expired or otherwise invalid, Shopify returns a `400 Bad Request`. An ID token lives about a minute, so this is a routine condition rather than a server fault, and a fresh ID token fixes it. Handle it the way you handle a failed validation check: respond with a `401` and the `X-Shopify-Retry-Invalid-Session-Request` header, so App Bridge fetches a new ID token and retries. Reserve `5xx` responses for failures that a fresh ID token can't fix, such as rejected client credentials.

## /node/index.js

```javascript
import 'dotenv/config';
import express from 'express';
import path from 'path';
import {readFileSync} from 'fs';
import {fileURLToPath} from 'url';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';


const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express();
app.use(express.json());


const {SHOPIFY_CLIENT_ID, SHOPIFY_CLIENT_SECRET, REFRESH_TASK_SECRET} =
  process.env;


// Inject the App Bridge API key (your client ID) into index.html before serving
// it. express.static would return the file verbatim, leaving the literal
// %SHOPIFY_API_KEY% placeholder in the page — so App Bridge never initializes.
app.get(['/', '/index.html'], (req, res) => {
  const html = readFileSync(
    path.join(__dirname, '..', 'public', 'index.html'),
    'utf8',
  ).replace('%SHOPIFY_API_KEY%', SHOPIFY_CLIENT_ID);
  res.type('html').send(html);
});


app.use(express.static(path.join(__dirname, '..', 'public')));


// In-memory token store (use a persistent session store in production)
const tokenStore = {};


// Shops whose Admin API calls must run under the acting staff member's own
// online token. A real app knows this statically — it's a property of how the
// app is built, not of what's in the store. This sample records it the first
// time /exchange/online succeeds so both flows stay demonstrable.
//
// The point of tracking it at all: once an app needs per-user authorization,
// a missing online token is a condition to recover from, never a reason to
// reach for the shop-wide offline token. That substitution would run a
// low-privileged staff member's request with the app's full access.
const perUserAuthorization = new Set();


// A valid expiring-token response includes expires_in (seconds until the access
// token expires). Return null when it's absent or non-positive: treat the token
// as non-expiring and never refresh it. Storing Date.now() instead would make
// the next request refresh a token that has no refresh_token — a permanent 401.
function expiresAtFrom(expiresIn) {
  const seconds = Number(expiresIn);
  return seconds > 0 ? Date.now() + seconds * 1000 : null;
}


// Node's fetch has no timeout, so a stalled connection to Shopify would hang a
// request until the client gives up. Give every call a deadline, and tag transport
// failures so callers can tell "Shopify said no" from "we never reached Shopify".
// fetch rejects only on a transport failure or this timeout: every HTTP status,
// including 5xx, resolves and is the caller's to handle.
const SHOPIFY_TIMEOUT_MS = 30_000;


class ShopifyUnreachable extends Error {}


async function shopifyFetch(url, options) {
  try {
    return await fetch(url, {
      ...options,
      signal: AbortSignal.timeout(SHOPIFY_TIMEOUT_MS),
    });
  } catch (cause) {
    throw new ShopifyUnreachable(`Could not reach ${new URL(url).hostname}`, {
      cause,
    });
  }
}


function validateIdToken(idToken) {
  const payload = jwt.verify(idToken, SHOPIFY_CLIENT_SECRET, {
    algorithms: ['HS256'],
    audience: SHOPIFY_CLIENT_ID,
  });


  const issuerHost = new URL(payload.iss).hostname;
  const destHost = new URL(payload.dest).hostname;
  if (issuerHost !== destHost) {
    throw new Error('Token issuer and destination do not match');
  }


  return payload;
}


// Background callers (webhooks, scheduled jobs) have no session to produce an
// ID token, so they authenticate with a shared secret that only your own
// backend and schedulers know. It's sent in the `X-Refresh-Secret` header.
function isAuthorizedTask(req) {
  const provided = req.get('X-Refresh-Secret') ?? '';
  if (!REFRESH_TASK_SECRET || !provided) return false;
  const a = Buffer.from(provided);
  const b = Buffer.from(REFRESH_TASK_SECRET);
  // timingSafeEqual throws on length mismatch, so check length first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}


// Exchange the stored offline refresh token for a new offline access token.
// The return value tells the caller how to react:
//   'refreshed'   — stored a new access token
//   'reauthorize' — no refresh token, or Shopify returned 401 (the refresh token
//                   is expired, revoked, replayed outside the retry window, or the
//                   app was uninstalled); the merchant must reinstall
//   'retry'       — a transient failure (network, 5xx, 429); safe to retry later
//   'failed'      — any other non-OK status, such as a malformed request or bad
//                   client credentials; the same request fails the same way, so
//                   surface it instead of hiding it behind the retry path
async function refreshOfflineToken(shop) {
  const stored = tokenStore[shop];
  if (!stored?.refresh_token) return 'reauthorize';


  let response;
  try {
    response = await shopifyFetch(`https://${shop}/admin/oauth/access_token`, {
      method: 'POST',
      headers: {'Content-Type': 'application/x-www-form-urlencoded'},
      body: new URLSearchParams({
        grant_type: 'refresh_token',
        client_id: SHOPIFY_CLIENT_ID,
        client_secret: SHOPIFY_CLIENT_SECRET,
        refresh_token: stored.refresh_token,
      }),
    });
  } catch (error) {
    // Only a transport failure or timeout becomes 'retry': the request never
    // reached Shopify, so the refresh token is untouched and a later attempt is
    // safe. Anything else is a bug in this code — let it surface.
    if (!(error instanceof ShopifyUnreachable)) throw error;
    return 'retry';
  }


  if (response.status === 401) {
    delete tokenStore[shop];
    return 'reauthorize';
  }
  // Only a rate limit or a server fault is worth retrying. Treating every other
  // non-OK status as transient would retry an unrecoverable refresh forever —
  // a 400 for a malformed body, or a 403 for bad client credentials, returns the
  // same response no matter how long you wait.
  if (response.status === 429 || response.status >= 500) return 'retry';
  if (!response.ok) return 'failed';


  const {access_token, refresh_token, expires_in} = await response.json();
  tokenStore[shop] = {
    access_token,
    refresh_token,
    expires_at: expiresAtFrom(expires_in),
  };
  return 'refreshed';
}


app.post('/exchange/offline', async (req, res) => {
  const idToken = req.headers.authorization?.replace('Bearer ', '');
  let payload;
  try {
    payload = validateIdToken(idToken);
  } catch {
    // Signal App Bridge to fetch a fresh ID token and retry this request once.
    res.set('X-Shopify-Retry-Invalid-Session-Request', '1');
    return res.status(401).json({error: 'Invalid ID token'});
  }


  const shop = new URL(payload.dest).hostname;


  const response = await shopifyFetch(
    `https://${shop}/admin/oauth/access_token`,
    {
      method: 'POST',
      headers: {'Content-Type': 'application/x-www-form-urlencoded'},
      body: new URLSearchParams({
        client_id: SHOPIFY_CLIENT_ID,
        client_secret: SHOPIFY_CLIENT_SECRET,
        grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
        subject_token: idToken,
        subject_token_type: 'urn:ietf:params:oauth:token-type:id_token',
        requested_token_type:
          'urn:shopify:params:oauth:token-type:offline-access-token',
        expiring: '1',
      }),
    },
  );


  // Shopify returns 400 when the ID token is expired or otherwise invalid. ID
  // tokens live about a minute, so that's a routine client condition, not a server
  // fault — answer it like a local validation failure so App Bridge fetches a fresh
  // token and retries. Returning 502 would say the opposite: don't bother retrying.
  if (response.status === 400) {
    res.set('X-Shopify-Retry-Invalid-Session-Request', '1');
    return res.status(401).json({error: 'Invalid ID token'});
  }


  if (!response.ok) {
    return res.status(502).json({error: 'Token exchange failed'});
  }


  const {access_token, refresh_token, scope, expires_in} = await response.json();


  // Store tokens server-side — never send them to the browser. Track expiry so
  // requests can refresh the offline token before it lapses.
  tokenStore[shop] = {
    access_token,
    refresh_token,
    expires_at: expiresAtFrom(expires_in),
  };


  res.json({scope});
});


app.post('/exchange/online', async (req, res) => {
  const idToken = req.headers.authorization?.replace('Bearer ', '');
  let payload;
  try {
    payload = validateIdToken(idToken);
  } catch {
    // Signal App Bridge to fetch a fresh ID token and retry this request once.
    res.set('X-Shopify-Retry-Invalid-Session-Request', '1');
    return res.status(401).json({error: 'Invalid ID token'});
  }


  const shop = new URL(payload.dest).hostname;


  const response = await shopifyFetch(
    `https://${shop}/admin/oauth/access_token`,
    {
      method: 'POST',
      headers: {'Content-Type': 'application/x-www-form-urlencoded'},
      body: new URLSearchParams({
        client_id: SHOPIFY_CLIENT_ID,
        client_secret: SHOPIFY_CLIENT_SECRET,
        grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
        subject_token: idToken,
        subject_token_type: 'urn:ietf:params:oauth:token-type:id_token',
        requested_token_type:
          'urn:shopify:params:oauth:token-type:online-access-token',
      }),
    },
  );


  // Same as the offline route: a 400 means the ID token is stale, which a fresh
  // one fixes. Don't dress a retryable condition up as a server error.
  if (response.status === 400) {
    res.set('X-Shopify-Retry-Invalid-Session-Request', '1');
    return res.status(401).json({error: 'Invalid ID token'});
  }


  if (!response.ok) {
    return res.status(502).json({error: 'Token exchange failed'});
  }


  const {access_token, scope, expires_in} = await response.json();


  // Online tokens are scoped to the staff member who authorized them, so key
  // them by user (the ID token's `sub`) — not just by shop. Storing under a
  // shop-only key would let one staff member's token overwrite another's.
  // Track expiry so a lapsed token is dropped rather than sent.
  tokenStore[`${shop}:online:${payload.sub}`] = {
    access_token,
    expires_at: expiresAtFrom(expires_in),
  };


  // This app authorizes Admin API calls per staff member for this shop from here
  // on. /api/shop reads this and mints a replacement online token when one is
  // missing, rather than borrowing the shop-wide offline token.
  perUserAuthorization.add(shop);


  res.json({scope});
});


// Re-run token exchange for a token Shopify rejected, using the ID token that
// came with the current request. Same request as /exchange/offline and
// /exchange/online, minting whichever kind was rejected. The return value tells
// the caller how to react:
//   'minted'           — stored a new access token
//   'invalid_id_token' — Shopify returned 400; the ID token is stale, and a fresh
//                        one fixes it
//   'failed'           — anything else; retrying with the same inputs won't help
async function remintAccessToken({idToken, shop, sub, online}) {
  const response = await shopifyFetch(
    `https://${shop}/admin/oauth/access_token`,
    {
      method: 'POST',
      headers: {'Content-Type': 'application/x-www-form-urlencoded'},
      body: new URLSearchParams({
        client_id: SHOPIFY_CLIENT_ID,
        client_secret: SHOPIFY_CLIENT_SECRET,
        grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
        subject_token: idToken,
        subject_token_type: 'urn:ietf:params:oauth:token-type:id_token',
        requested_token_type: online
          ? 'urn:shopify:params:oauth:token-type:online-access-token'
          : 'urn:shopify:params:oauth:token-type:offline-access-token',
        // `expiring` only applies to offline tokens. An online token already
        // expires with the staff member's session.
        ...(online ? {} : {expiring: '1'}),
      }),
    },
  );


  if (response.status === 400) return 'invalid_id_token';
  if (!response.ok) return 'failed';


  const {access_token, refresh_token, expires_in} = await response.json();


  if (online) {
    tokenStore[`${shop}:online:${sub}`] = {
      access_token,
      expires_at: expiresAtFrom(expires_in),
    };
  } else {
    tokenStore[shop] = {
      access_token,
      refresh_token,
      expires_at: expiresAtFrom(expires_in),
    };
  }


  return 'minted';
}


app.get('/api/shop', async (req, res) => {
  const idToken = req.headers.authorization?.replace('Bearer ', '');
  let payload;
  try {
    payload = validateIdToken(idToken);
  } catch {
    // Signal App Bridge to fetch a fresh ID token and retry this request once.
    res.set('X-Shopify-Retry-Invalid-Session-Request', '1');
    return res.status(401).json({error: 'Invalid ID token'});
  }


  const shop = new URL(payload.dest).hostname;
  const onlineKey = `${shop}:online:${payload.sub}`;


  // Drop an expired online token rather than sending a dead credential. Online
  // tokens can't be refreshed — a new one is minted from a fresh ID token.
  const online = tokenStore[onlineKey];
  if (online?.expires_at && online.expires_at <= Date.now()) {
    delete tokenStore[onlineKey];
  }


  // Which token authorizes this call is decided by how the app is built, not by
  // what happens to be in the store. Deciding it by availability instead — the
  // `tokenStore[onlineKey] ?? tokenStore[shop]` this used to do — means that
  // whenever a staff member's online token is missing or expired, and online
  // tokens expire at logout or after 24 hours, their request quietly goes out
  // under the app's shop-wide offline token with the app's full scopes. The
  // Admin API then enforces nothing about that user, so a staff member without
  // permission for an action gets it anyway.
  const usingOnline = perUserAuthorization.has(shop);


  // Per-user app, no usable online token for this staff member: mint one from
  // the ID token this request already carries. This is the recovery the fallback
  // was standing in for, and it costs one token exchange.
  if (usingOnline && !tokenStore[onlineKey]) {
    const result = await remintAccessToken({
      idToken,
      shop,
      sub: payload.sub,
      online: true,
    });


    if (result === 'invalid_id_token') {
      res.set('X-Shopify-Retry-Invalid-Session-Request', '1');
      return res.status(401).json({error: 'Invalid ID token'});
    }
    if (result !== 'minted') {
      return res.status(502).json({error: 'Token exchange failed'});
    }
  }


  // Only the offline token is refreshable (online tokens are re-minted from a
  // fresh ID token via /exchange/online). Refresh it ~60 seconds before it
  // expires — but only when we're about to use it, so a still-valid online token
  // isn't blocked by a failed offline refresh.
  if (!usingOnline) {
    const offline = tokenStore[shop];
    if (offline?.expires_at && Date.now() >= offline.expires_at - 60 * 1000) {
      const result = await refreshOfflineToken(shop);
      if (result === 'reauthorize') {
        // No retry header here: the offline refresh token is dead, and a fresh
        // ID token can't revive it. The client must re-run /exchange/offline
        // (or reinstall) rather than retry this request into another 401.
        return res.status(401).json({error: 'reauthenticate'});
      }
      if (result === 'retry') {
        return res.status(503).json({error: 'Token refresh failed, try again'});
      }
      if (result === 'failed') {
        // Not transient and not the merchant's problem. Don't fall through — the
        // stored token is about to expire, so the request below would go out with
        // a credential that's already lapsing.
        return res.status(502).json({error: 'Token refresh rejected'});
      }
    }
  }


  // No `??` here on purpose: each mode reads only its own token, so a missing
  // one is a 401 rather than a silent upgrade to broader access.
  const stored = usingOnline ? tokenStore[onlineKey] : tokenStore[shop];
  if (!stored) return res.status(401).json({error: 'Not authenticated'});


  const callAdminApi = (accessToken) =>
    shopifyFetch(`https://${shop}/admin/api/2026-04/graphql.json`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-Shopify-Access-Token': accessToken,
      },
      body: JSON.stringify({query: '{ shop { name } }'}),
    });


  let response = await callAdminApi(stored.access_token);


  // Shopify rejected the access token (revoked, or the app's scopes changed).
  // Evict it and mint a replacement with token exchange: this request already
  // carries a validated ID token, so nothing the merchant does is needed.
  //
  // Don't answer this with X-Shopify-Retry-Invalid-Session-Request. That header
  // only makes App Bridge fetch a fresh *ID* token and replay this request — it
  // never re-runs the exchange, so the replay would find no stored token and fail
  // with "Not authenticated". It's the right answer for a rejected ID token, and
  // the wrong one for a rejected access token.
  if (response.status === 401) {
    const evictKey = usingOnline ? onlineKey : shop;
    delete tokenStore[evictKey];


    const result = await remintAccessToken({
      idToken,
      shop,
      sub: payload.sub,
      online: usingOnline,
    });


    // Only now is the retry header correct: the ID token itself is stale, and a
    // fresh one lets the replayed request mint a token successfully.
    if (result === 'invalid_id_token') {
      res.set('X-Shopify-Retry-Invalid-Session-Request', '1');
      return res.status(401).json({error: 'Invalid ID token'});
    }
    if (result !== 'minted') {
      return res.status(502).json({error: 'Token exchange failed'});
    }


    response = await callAdminApi(tokenStore[evictKey].access_token);


    // Retry once, not in a loop. A freshly minted token that's also rejected
    // means something is wrong beyond a stale credential, so stop and tell the
    // merchant to reauthorize rather than mint tokens indefinitely.
    if (response.status === 401) {
      delete tokenStore[evictKey];
      return res.status(401).json({error: 'reauthenticate'});
    }
  }


  // Forward Shopify's status. Answering a rate limit or an outage with a 200 and
  // an error body in it would tell the client the request succeeded.
  res.status(response.status).json(await response.json());
});


app.post('/refresh', async (req, res) => {
  // This endpoint mints a new access token, so authenticate the caller first.
  // Background callers (webhooks, scheduled jobs) have no session to produce an
  // ID token, so they send a shared secret in the `X-Refresh-Secret` header.
  if (!isAuthorizedTask(req)) {
    return res.status(401).json({error: 'Unauthorized'});
  }


  // Background callers supply the shop domain directly, since they have no
  // active session to derive it from an ID token. Defaulting to {} keeps a
  // request with no body — or the wrong content type — on the documented 400
  // path: Express 5 leaves req.body undefined when nothing was parsed, so
  // destructuring it directly would throw and return 500.
  const {shop} = req.body ?? {};
  if (!shop) return res.status(400).json({error: 'Missing shop'});


  // A 401 is terminal (expired, revoked, replayed outside the retry window, or
  // the app was uninstalled): re-authenticate the next time a merchant opens the
  // app. Other failures (network, 5xx, 429) are transient and safe to retry.
  const result = await refreshOfflineToken(shop);
  if (result === 'reauthorize') {
    return res.status(401).json({error: 'reauthenticate'});
  }
  if (result === 'retry') {
    return res.status(502).json({error: 'Token refresh failed'});
  }
  if (result === 'failed') {
    // Not transient: retrying sends the identical request. Answering with
    // success here would tell the client it holds a fresh token when it doesn't.
    return res.status(502).json({error: 'Token refresh rejected'});
  }


  res.json({success: true});
});


// express.json() throws on a malformed body, and Express's default error handler
// answers with an HTML page containing a stack trace and absolute file paths. This
// is a JSON API, so answer in JSON. Match only parse failures: anything else should
// keep surfacing loudly rather than be swallowed here.
app.use((err, req, res, next) => {
  if (err?.type === 'entity.parse.failed') {
    return res.status(400).json({error: 'Malformed JSON body'});
  }
  // The request never reached Shopify, so nothing was consumed and the caller can
  // try again. 503 says that; the stack trace Express would otherwise return says
  // the app is broken.
  if (err instanceof ShopifyUnreachable) {
    return res.status(503).json({error: 'Could not reach Shopify, try again'});
  }
  next(err);
});


app.listen(3000, () => console.log('Server running on http://localhost:3000'));
```

### Handle the offline token response

Store the `access_token` and `refresh_token` securely on your backend, along with `expires_in` and `refresh_token_expires_in` so you know when each one expires. Never expose them to the browser.

Response fields

| Field | Description |
| - | - |
| `access_token` | The token to include in API requests. |
| `scope` | The [access scopes](https://shopify.dev/docs/api/usage/access-scopes) granted to your app. The exchange doesn't ask for scopes, so this is a readback of what you configured for your app. To add or change them, see [Manage access scopes](https://shopify.dev/docs/apps/build/authentication-authorization/manage-access-scopes). |
| `expires_in` | Seconds until the token expires. |
| `refresh_token` | Used to get a new access token before expiry. |
| `refresh_token_expires_in` | Seconds until the refresh token expires (90 days). |

## (Optional) Request an online access token

Online access tokens are scoped to the current user and session. Use them if your app needs to enforce per-user permissions or attribute actions to a specific staff member. Skip this step if offline access tokens meet your needs. For more about online tokens, including their lifetime and the `associated_user` fields, see [Online access tokens](https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens#online-access-tokens).

### Exchange the ID token for an online token

Each online token is valid for 24 hours and tied to the staff member who opened your app. As with the offline exchange, a `400 Bad Request` means the ID token is stale, so answer it with a `401` and the retry header.

## /node/index.js

```javascript
import 'dotenv/config';
import express from 'express';
import path from 'path';
import {readFileSync} from 'fs';
import {fileURLToPath} from 'url';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';


const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express();
app.use(express.json());


const {SHOPIFY_CLIENT_ID, SHOPIFY_CLIENT_SECRET, REFRESH_TASK_SECRET} =
  process.env;


// Inject the App Bridge API key (your client ID) into index.html before serving
// it. express.static would return the file verbatim, leaving the literal
// %SHOPIFY_API_KEY% placeholder in the page — so App Bridge never initializes.
app.get(['/', '/index.html'], (req, res) => {
  const html = readFileSync(
    path.join(__dirname, '..', 'public', 'index.html'),
    'utf8',
  ).replace('%SHOPIFY_API_KEY%', SHOPIFY_CLIENT_ID);
  res.type('html').send(html);
});


app.use(express.static(path.join(__dirname, '..', 'public')));


// In-memory token store (use a persistent session store in production)
const tokenStore = {};


// Shops whose Admin API calls must run under the acting staff member's own
// online token. A real app knows this statically — it's a property of how the
// app is built, not of what's in the store. This sample records it the first
// time /exchange/online succeeds so both flows stay demonstrable.
//
// The point of tracking it at all: once an app needs per-user authorization,
// a missing online token is a condition to recover from, never a reason to
// reach for the shop-wide offline token. That substitution would run a
// low-privileged staff member's request with the app's full access.
const perUserAuthorization = new Set();


// A valid expiring-token response includes expires_in (seconds until the access
// token expires). Return null when it's absent or non-positive: treat the token
// as non-expiring and never refresh it. Storing Date.now() instead would make
// the next request refresh a token that has no refresh_token — a permanent 401.
function expiresAtFrom(expiresIn) {
  const seconds = Number(expiresIn);
  return seconds > 0 ? Date.now() + seconds * 1000 : null;
}


// Node's fetch has no timeout, so a stalled connection to Shopify would hang a
// request until the client gives up. Give every call a deadline, and tag transport
// failures so callers can tell "Shopify said no" from "we never reached Shopify".
// fetch rejects only on a transport failure or this timeout: every HTTP status,
// including 5xx, resolves and is the caller's to handle.
const SHOPIFY_TIMEOUT_MS = 30_000;


class ShopifyUnreachable extends Error {}


async function shopifyFetch(url, options) {
  try {
    return await fetch(url, {
      ...options,
      signal: AbortSignal.timeout(SHOPIFY_TIMEOUT_MS),
    });
  } catch (cause) {
    throw new ShopifyUnreachable(`Could not reach ${new URL(url).hostname}`, {
      cause,
    });
  }
}


function validateIdToken(idToken) {
  const payload = jwt.verify(idToken, SHOPIFY_CLIENT_SECRET, {
    algorithms: ['HS256'],
    audience: SHOPIFY_CLIENT_ID,
  });


  const issuerHost = new URL(payload.iss).hostname;
  const destHost = new URL(payload.dest).hostname;
  if (issuerHost !== destHost) {
    throw new Error('Token issuer and destination do not match');
  }


  return payload;
}


// Background callers (webhooks, scheduled jobs) have no session to produce an
// ID token, so they authenticate with a shared secret that only your own
// backend and schedulers know. It's sent in the `X-Refresh-Secret` header.
function isAuthorizedTask(req) {
  const provided = req.get('X-Refresh-Secret') ?? '';
  if (!REFRESH_TASK_SECRET || !provided) return false;
  const a = Buffer.from(provided);
  const b = Buffer.from(REFRESH_TASK_SECRET);
  // timingSafeEqual throws on length mismatch, so check length first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}


// Exchange the stored offline refresh token for a new offline access token.
// The return value tells the caller how to react:
//   'refreshed'   — stored a new access token
//   'reauthorize' — no refresh token, or Shopify returned 401 (the refresh token
//                   is expired, revoked, replayed outside the retry window, or the
//                   app was uninstalled); the merchant must reinstall
//   'retry'       — a transient failure (network, 5xx, 429); safe to retry later
//   'failed'      — any other non-OK status, such as a malformed request or bad
//                   client credentials; the same request fails the same way, so
//                   surface it instead of hiding it behind the retry path
async function refreshOfflineToken(shop) {
  const stored = tokenStore[shop];
  if (!stored?.refresh_token) return 'reauthorize';


  let response;
  try {
    response = await shopifyFetch(`https://${shop}/admin/oauth/access_token`, {
      method: 'POST',
      headers: {'Content-Type': 'application/x-www-form-urlencoded'},
      body: new URLSearchParams({
        grant_type: 'refresh_token',
        client_id: SHOPIFY_CLIENT_ID,
        client_secret: SHOPIFY_CLIENT_SECRET,
        refresh_token: stored.refresh_token,
      }),
    });
  } catch (error) {
    // Only a transport failure or timeout becomes 'retry': the request never
    // reached Shopify, so the refresh token is untouched and a later attempt is
    // safe. Anything else is a bug in this code — let it surface.
    if (!(error instanceof ShopifyUnreachable)) throw error;
    return 'retry';
  }


  if (response.status === 401) {
    delete tokenStore[shop];
    return 'reauthorize';
  }
  // Only a rate limit or a server fault is worth retrying. Treating every other
  // non-OK status as transient would retry an unrecoverable refresh forever —
  // a 400 for a malformed body, or a 403 for bad client credentials, returns the
  // same response no matter how long you wait.
  if (response.status === 429 || response.status >= 500) return 'retry';
  if (!response.ok) return 'failed';


  const {access_token, refresh_token, expires_in} = await response.json();
  tokenStore[shop] = {
    access_token,
    refresh_token,
    expires_at: expiresAtFrom(expires_in),
  };
  return 'refreshed';
}


app.post('/exchange/offline', async (req, res) => {
  const idToken = req.headers.authorization?.replace('Bearer ', '');
  let payload;
  try {
    payload = validateIdToken(idToken);
  } catch {
    // Signal App Bridge to fetch a fresh ID token and retry this request once.
    res.set('X-Shopify-Retry-Invalid-Session-Request', '1');
    return res.status(401).json({error: 'Invalid ID token'});
  }


  const shop = new URL(payload.dest).hostname;


  const response = await shopifyFetch(
    `https://${shop}/admin/oauth/access_token`,
    {
      method: 'POST',
      headers: {'Content-Type': 'application/x-www-form-urlencoded'},
      body: new URLSearchParams({
        client_id: SHOPIFY_CLIENT_ID,
        client_secret: SHOPIFY_CLIENT_SECRET,
        grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
        subject_token: idToken,
        subject_token_type: 'urn:ietf:params:oauth:token-type:id_token',
        requested_token_type:
          'urn:shopify:params:oauth:token-type:offline-access-token',
        expiring: '1',
      }),
    },
  );


  // Shopify returns 400 when the ID token is expired or otherwise invalid. ID
  // tokens live about a minute, so that's a routine client condition, not a server
  // fault — answer it like a local validation failure so App Bridge fetches a fresh
  // token and retries. Returning 502 would say the opposite: don't bother retrying.
  if (response.status === 400) {
    res.set('X-Shopify-Retry-Invalid-Session-Request', '1');
    return res.status(401).json({error: 'Invalid ID token'});
  }


  if (!response.ok) {
    return res.status(502).json({error: 'Token exchange failed'});
  }


  const {access_token, refresh_token, scope, expires_in} = await response.json();


  // Store tokens server-side — never send them to the browser. Track expiry so
  // requests can refresh the offline token before it lapses.
  tokenStore[shop] = {
    access_token,
    refresh_token,
    expires_at: expiresAtFrom(expires_in),
  };


  res.json({scope});
});


app.post('/exchange/online', async (req, res) => {
  const idToken = req.headers.authorization?.replace('Bearer ', '');
  let payload;
  try {
    payload = validateIdToken(idToken);
  } catch {
    // Signal App Bridge to fetch a fresh ID token and retry this request once.
    res.set('X-Shopify-Retry-Invalid-Session-Request', '1');
    return res.status(401).json({error: 'Invalid ID token'});
  }


  const shop = new URL(payload.dest).hostname;


  const response = await shopifyFetch(
    `https://${shop}/admin/oauth/access_token`,
    {
      method: 'POST',
      headers: {'Content-Type': 'application/x-www-form-urlencoded'},
      body: new URLSearchParams({
        client_id: SHOPIFY_CLIENT_ID,
        client_secret: SHOPIFY_CLIENT_SECRET,
        grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
        subject_token: idToken,
        subject_token_type: 'urn:ietf:params:oauth:token-type:id_token',
        requested_token_type:
          'urn:shopify:params:oauth:token-type:online-access-token',
      }),
    },
  );


  // Same as the offline route: a 400 means the ID token is stale, which a fresh
  // one fixes. Don't dress a retryable condition up as a server error.
  if (response.status === 400) {
    res.set('X-Shopify-Retry-Invalid-Session-Request', '1');
    return res.status(401).json({error: 'Invalid ID token'});
  }


  if (!response.ok) {
    return res.status(502).json({error: 'Token exchange failed'});
  }


  const {access_token, scope, expires_in} = await response.json();


  // Online tokens are scoped to the staff member who authorized them, so key
  // them by user (the ID token's `sub`) — not just by shop. Storing under a
  // shop-only key would let one staff member's token overwrite another's.
  // Track expiry so a lapsed token is dropped rather than sent.
  tokenStore[`${shop}:online:${payload.sub}`] = {
    access_token,
    expires_at: expiresAtFrom(expires_in),
  };


  // This app authorizes Admin API calls per staff member for this shop from here
  // on. /api/shop reads this and mints a replacement online token when one is
  // missing, rather than borrowing the shop-wide offline token.
  perUserAuthorization.add(shop);


  res.json({scope});
});


// Re-run token exchange for a token Shopify rejected, using the ID token that
// came with the current request. Same request as /exchange/offline and
// /exchange/online, minting whichever kind was rejected. The return value tells
// the caller how to react:
//   'minted'           — stored a new access token
//   'invalid_id_token' — Shopify returned 400; the ID token is stale, and a fresh
//                        one fixes it
//   'failed'           — anything else; retrying with the same inputs won't help
async function remintAccessToken({idToken, shop, sub, online}) {
  const response = await shopifyFetch(
    `https://${shop}/admin/oauth/access_token`,
    {
      method: 'POST',
      headers: {'Content-Type': 'application/x-www-form-urlencoded'},
      body: new URLSearchParams({
        client_id: SHOPIFY_CLIENT_ID,
        client_secret: SHOPIFY_CLIENT_SECRET,
        grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
        subject_token: idToken,
        subject_token_type: 'urn:ietf:params:oauth:token-type:id_token',
        requested_token_type: online
          ? 'urn:shopify:params:oauth:token-type:online-access-token'
          : 'urn:shopify:params:oauth:token-type:offline-access-token',
        // `expiring` only applies to offline tokens. An online token already
        // expires with the staff member's session.
        ...(online ? {} : {expiring: '1'}),
      }),
    },
  );


  if (response.status === 400) return 'invalid_id_token';
  if (!response.ok) return 'failed';


  const {access_token, refresh_token, expires_in} = await response.json();


  if (online) {
    tokenStore[`${shop}:online:${sub}`] = {
      access_token,
      expires_at: expiresAtFrom(expires_in),
    };
  } else {
    tokenStore[shop] = {
      access_token,
      refresh_token,
      expires_at: expiresAtFrom(expires_in),
    };
  }


  return 'minted';
}


app.get('/api/shop', async (req, res) => {
  const idToken = req.headers.authorization?.replace('Bearer ', '');
  let payload;
  try {
    payload = validateIdToken(idToken);
  } catch {
    // Signal App Bridge to fetch a fresh ID token and retry this request once.
    res.set('X-Shopify-Retry-Invalid-Session-Request', '1');
    return res.status(401).json({error: 'Invalid ID token'});
  }


  const shop = new URL(payload.dest).hostname;
  const onlineKey = `${shop}:online:${payload.sub}`;


  // Drop an expired online token rather than sending a dead credential. Online
  // tokens can't be refreshed — a new one is minted from a fresh ID token.
  const online = tokenStore[onlineKey];
  if (online?.expires_at && online.expires_at <= Date.now()) {
    delete tokenStore[onlineKey];
  }


  // Which token authorizes this call is decided by how the app is built, not by
  // what happens to be in the store. Deciding it by availability instead — the
  // `tokenStore[onlineKey] ?? tokenStore[shop]` this used to do — means that
  // whenever a staff member's online token is missing or expired, and online
  // tokens expire at logout or after 24 hours, their request quietly goes out
  // under the app's shop-wide offline token with the app's full scopes. The
  // Admin API then enforces nothing about that user, so a staff member without
  // permission for an action gets it anyway.
  const usingOnline = perUserAuthorization.has(shop);


  // Per-user app, no usable online token for this staff member: mint one from
  // the ID token this request already carries. This is the recovery the fallback
  // was standing in for, and it costs one token exchange.
  if (usingOnline && !tokenStore[onlineKey]) {
    const result = await remintAccessToken({
      idToken,
      shop,
      sub: payload.sub,
      online: true,
    });


    if (result === 'invalid_id_token') {
      res.set('X-Shopify-Retry-Invalid-Session-Request', '1');
      return res.status(401).json({error: 'Invalid ID token'});
    }
    if (result !== 'minted') {
      return res.status(502).json({error: 'Token exchange failed'});
    }
  }


  // Only the offline token is refreshable (online tokens are re-minted from a
  // fresh ID token via /exchange/online). Refresh it ~60 seconds before it
  // expires — but only when we're about to use it, so a still-valid online token
  // isn't blocked by a failed offline refresh.
  if (!usingOnline) {
    const offline = tokenStore[shop];
    if (offline?.expires_at && Date.now() >= offline.expires_at - 60 * 1000) {
      const result = await refreshOfflineToken(shop);
      if (result === 'reauthorize') {
        // No retry header here: the offline refresh token is dead, and a fresh
        // ID token can't revive it. The client must re-run /exchange/offline
        // (or reinstall) rather than retry this request into another 401.
        return res.status(401).json({error: 'reauthenticate'});
      }
      if (result === 'retry') {
        return res.status(503).json({error: 'Token refresh failed, try again'});
      }
      if (result === 'failed') {
        // Not transient and not the merchant's problem. Don't fall through — the
        // stored token is about to expire, so the request below would go out with
        // a credential that's already lapsing.
        return res.status(502).json({error: 'Token refresh rejected'});
      }
    }
  }


  // No `??` here on purpose: each mode reads only its own token, so a missing
  // one is a 401 rather than a silent upgrade to broader access.
  const stored = usingOnline ? tokenStore[onlineKey] : tokenStore[shop];
  if (!stored) return res.status(401).json({error: 'Not authenticated'});


  const callAdminApi = (accessToken) =>
    shopifyFetch(`https://${shop}/admin/api/2026-04/graphql.json`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-Shopify-Access-Token': accessToken,
      },
      body: JSON.stringify({query: '{ shop { name } }'}),
    });


  let response = await callAdminApi(stored.access_token);


  // Shopify rejected the access token (revoked, or the app's scopes changed).
  // Evict it and mint a replacement with token exchange: this request already
  // carries a validated ID token, so nothing the merchant does is needed.
  //
  // Don't answer this with X-Shopify-Retry-Invalid-Session-Request. That header
  // only makes App Bridge fetch a fresh *ID* token and replay this request — it
  // never re-runs the exchange, so the replay would find no stored token and fail
  // with "Not authenticated". It's the right answer for a rejected ID token, and
  // the wrong one for a rejected access token.
  if (response.status === 401) {
    const evictKey = usingOnline ? onlineKey : shop;
    delete tokenStore[evictKey];


    const result = await remintAccessToken({
      idToken,
      shop,
      sub: payload.sub,
      online: usingOnline,
    });


    // Only now is the retry header correct: the ID token itself is stale, and a
    // fresh one lets the replayed request mint a token successfully.
    if (result === 'invalid_id_token') {
      res.set('X-Shopify-Retry-Invalid-Session-Request', '1');
      return res.status(401).json({error: 'Invalid ID token'});
    }
    if (result !== 'minted') {
      return res.status(502).json({error: 'Token exchange failed'});
    }


    response = await callAdminApi(tokenStore[evictKey].access_token);


    // Retry once, not in a loop. A freshly minted token that's also rejected
    // means something is wrong beyond a stale credential, so stop and tell the
    // merchant to reauthorize rather than mint tokens indefinitely.
    if (response.status === 401) {
      delete tokenStore[evictKey];
      return res.status(401).json({error: 'reauthenticate'});
    }
  }


  // Forward Shopify's status. Answering a rate limit or an outage with a 200 and
  // an error body in it would tell the client the request succeeded.
  res.status(response.status).json(await response.json());
});


app.post('/refresh', async (req, res) => {
  // This endpoint mints a new access token, so authenticate the caller first.
  // Background callers (webhooks, scheduled jobs) have no session to produce an
  // ID token, so they send a shared secret in the `X-Refresh-Secret` header.
  if (!isAuthorizedTask(req)) {
    return res.status(401).json({error: 'Unauthorized'});
  }


  // Background callers supply the shop domain directly, since they have no
  // active session to derive it from an ID token. Defaulting to {} keeps a
  // request with no body — or the wrong content type — on the documented 400
  // path: Express 5 leaves req.body undefined when nothing was parsed, so
  // destructuring it directly would throw and return 500.
  const {shop} = req.body ?? {};
  if (!shop) return res.status(400).json({error: 'Missing shop'});


  // A 401 is terminal (expired, revoked, replayed outside the retry window, or
  // the app was uninstalled): re-authenticate the next time a merchant opens the
  // app. Other failures (network, 5xx, 429) are transient and safe to retry.
  const result = await refreshOfflineToken(shop);
  if (result === 'reauthorize') {
    return res.status(401).json({error: 'reauthenticate'});
  }
  if (result === 'retry') {
    return res.status(502).json({error: 'Token refresh failed'});
  }
  if (result === 'failed') {
    // Not transient: retrying sends the identical request. Answering with
    // success here would tell the client it holds a fresh token when it doesn't.
    return res.status(502).json({error: 'Token refresh rejected'});
  }


  res.json({success: true});
});


// express.json() throws on a malformed body, and Express's default error handler
// answers with an HTML page containing a stack trace and absolute file paths. This
// is a JSON API, so answer in JSON. Match only parse failures: anything else should
// keep surfacing loudly rather than be swallowed here.
app.use((err, req, res, next) => {
  if (err?.type === 'entity.parse.failed') {
    return res.status(400).json({error: 'Malformed JSON body'});
  }
  // The request never reached Shopify, so nothing was consumed and the caller can
  // try again. 503 says that; the stack trace Express would otherwise return says
  // the app is broken.
  if (err instanceof ShopifyUnreachable) {
    return res.status(503).json({error: 'Could not reach Shopify, try again'});
  }
  next(err);
});


app.listen(3000, () => console.log('Server running on http://localhost:3000'));
```

### Handle the online token response

The `associated_user_scope` field contains the intersection of your app's scopes and that user's permissions. Use it to enforce what the current staff member can do.

Response fields

| Field | Description |
| - | - |
| `access_token` | The token to include in API requests. Valid for 24 hours. |
| `scope` | The [access scopes](https://shopify.dev/docs/api/usage/access-scopes) granted to your app. |
| `expires_in` | Seconds until the token expires. |
| `associated_user_scope` | The intersection of your app's scopes and the user's permissions. |
| `associated_user` | The authenticated user. Only trust `email` if `email_verified` is `true`. |

## Make authenticated API requests

Include the access token in the `X-Shopify-Access-Token` header on all GraphQL Admin API requests.

### Add the access token header

Include the access token in every request. If the access token expires or Shopify rejects it during an active merchant session, re-run token exchange with the ID token from the current request. For background jobs with no active session, use the `refresh_token` instead. See [Refresh an expiring offline token](#refresh-an-expiring-offline-token).

## /node/index.js

```javascript
import 'dotenv/config';
import express from 'express';
import path from 'path';
import {readFileSync} from 'fs';
import {fileURLToPath} from 'url';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';


const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express();
app.use(express.json());


const {SHOPIFY_CLIENT_ID, SHOPIFY_CLIENT_SECRET, REFRESH_TASK_SECRET} =
  process.env;


// Inject the App Bridge API key (your client ID) into index.html before serving
// it. express.static would return the file verbatim, leaving the literal
// %SHOPIFY_API_KEY% placeholder in the page — so App Bridge never initializes.
app.get(['/', '/index.html'], (req, res) => {
  const html = readFileSync(
    path.join(__dirname, '..', 'public', 'index.html'),
    'utf8',
  ).replace('%SHOPIFY_API_KEY%', SHOPIFY_CLIENT_ID);
  res.type('html').send(html);
});


app.use(express.static(path.join(__dirname, '..', 'public')));


// In-memory token store (use a persistent session store in production)
const tokenStore = {};


// Shops whose Admin API calls must run under the acting staff member's own
// online token. A real app knows this statically — it's a property of how the
// app is built, not of what's in the store. This sample records it the first
// time /exchange/online succeeds so both flows stay demonstrable.
//
// The point of tracking it at all: once an app needs per-user authorization,
// a missing online token is a condition to recover from, never a reason to
// reach for the shop-wide offline token. That substitution would run a
// low-privileged staff member's request with the app's full access.
const perUserAuthorization = new Set();


// A valid expiring-token response includes expires_in (seconds until the access
// token expires). Return null when it's absent or non-positive: treat the token
// as non-expiring and never refresh it. Storing Date.now() instead would make
// the next request refresh a token that has no refresh_token — a permanent 401.
function expiresAtFrom(expiresIn) {
  const seconds = Number(expiresIn);
  return seconds > 0 ? Date.now() + seconds * 1000 : null;
}


// Node's fetch has no timeout, so a stalled connection to Shopify would hang a
// request until the client gives up. Give every call a deadline, and tag transport
// failures so callers can tell "Shopify said no" from "we never reached Shopify".
// fetch rejects only on a transport failure or this timeout: every HTTP status,
// including 5xx, resolves and is the caller's to handle.
const SHOPIFY_TIMEOUT_MS = 30_000;


class ShopifyUnreachable extends Error {}


async function shopifyFetch(url, options) {
  try {
    return await fetch(url, {
      ...options,
      signal: AbortSignal.timeout(SHOPIFY_TIMEOUT_MS),
    });
  } catch (cause) {
    throw new ShopifyUnreachable(`Could not reach ${new URL(url).hostname}`, {
      cause,
    });
  }
}


function validateIdToken(idToken) {
  const payload = jwt.verify(idToken, SHOPIFY_CLIENT_SECRET, {
    algorithms: ['HS256'],
    audience: SHOPIFY_CLIENT_ID,
  });


  const issuerHost = new URL(payload.iss).hostname;
  const destHost = new URL(payload.dest).hostname;
  if (issuerHost !== destHost) {
    throw new Error('Token issuer and destination do not match');
  }


  return payload;
}


// Background callers (webhooks, scheduled jobs) have no session to produce an
// ID token, so they authenticate with a shared secret that only your own
// backend and schedulers know. It's sent in the `X-Refresh-Secret` header.
function isAuthorizedTask(req) {
  const provided = req.get('X-Refresh-Secret') ?? '';
  if (!REFRESH_TASK_SECRET || !provided) return false;
  const a = Buffer.from(provided);
  const b = Buffer.from(REFRESH_TASK_SECRET);
  // timingSafeEqual throws on length mismatch, so check length first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}


// Exchange the stored offline refresh token for a new offline access token.
// The return value tells the caller how to react:
//   'refreshed'   — stored a new access token
//   'reauthorize' — no refresh token, or Shopify returned 401 (the refresh token
//                   is expired, revoked, replayed outside the retry window, or the
//                   app was uninstalled); the merchant must reinstall
//   'retry'       — a transient failure (network, 5xx, 429); safe to retry later
//   'failed'      — any other non-OK status, such as a malformed request or bad
//                   client credentials; the same request fails the same way, so
//                   surface it instead of hiding it behind the retry path
async function refreshOfflineToken(shop) {
  const stored = tokenStore[shop];
  if (!stored?.refresh_token) return 'reauthorize';


  let response;
  try {
    response = await shopifyFetch(`https://${shop}/admin/oauth/access_token`, {
      method: 'POST',
      headers: {'Content-Type': 'application/x-www-form-urlencoded'},
      body: new URLSearchParams({
        grant_type: 'refresh_token',
        client_id: SHOPIFY_CLIENT_ID,
        client_secret: SHOPIFY_CLIENT_SECRET,
        refresh_token: stored.refresh_token,
      }),
    });
  } catch (error) {
    // Only a transport failure or timeout becomes 'retry': the request never
    // reached Shopify, so the refresh token is untouched and a later attempt is
    // safe. Anything else is a bug in this code — let it surface.
    if (!(error instanceof ShopifyUnreachable)) throw error;
    return 'retry';
  }


  if (response.status === 401) {
    delete tokenStore[shop];
    return 'reauthorize';
  }
  // Only a rate limit or a server fault is worth retrying. Treating every other
  // non-OK status as transient would retry an unrecoverable refresh forever —
  // a 400 for a malformed body, or a 403 for bad client credentials, returns the
  // same response no matter how long you wait.
  if (response.status === 429 || response.status >= 500) return 'retry';
  if (!response.ok) return 'failed';


  const {access_token, refresh_token, expires_in} = await response.json();
  tokenStore[shop] = {
    access_token,
    refresh_token,
    expires_at: expiresAtFrom(expires_in),
  };
  return 'refreshed';
}


app.post('/exchange/offline', async (req, res) => {
  const idToken = req.headers.authorization?.replace('Bearer ', '');
  let payload;
  try {
    payload = validateIdToken(idToken);
  } catch {
    // Signal App Bridge to fetch a fresh ID token and retry this request once.
    res.set('X-Shopify-Retry-Invalid-Session-Request', '1');
    return res.status(401).json({error: 'Invalid ID token'});
  }


  const shop = new URL(payload.dest).hostname;


  const response = await shopifyFetch(
    `https://${shop}/admin/oauth/access_token`,
    {
      method: 'POST',
      headers: {'Content-Type': 'application/x-www-form-urlencoded'},
      body: new URLSearchParams({
        client_id: SHOPIFY_CLIENT_ID,
        client_secret: SHOPIFY_CLIENT_SECRET,
        grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
        subject_token: idToken,
        subject_token_type: 'urn:ietf:params:oauth:token-type:id_token',
        requested_token_type:
          'urn:shopify:params:oauth:token-type:offline-access-token',
        expiring: '1',
      }),
    },
  );


  // Shopify returns 400 when the ID token is expired or otherwise invalid. ID
  // tokens live about a minute, so that's a routine client condition, not a server
  // fault — answer it like a local validation failure so App Bridge fetches a fresh
  // token and retries. Returning 502 would say the opposite: don't bother retrying.
  if (response.status === 400) {
    res.set('X-Shopify-Retry-Invalid-Session-Request', '1');
    return res.status(401).json({error: 'Invalid ID token'});
  }


  if (!response.ok) {
    return res.status(502).json({error: 'Token exchange failed'});
  }


  const {access_token, refresh_token, scope, expires_in} = await response.json();


  // Store tokens server-side — never send them to the browser. Track expiry so
  // requests can refresh the offline token before it lapses.
  tokenStore[shop] = {
    access_token,
    refresh_token,
    expires_at: expiresAtFrom(expires_in),
  };


  res.json({scope});
});


app.post('/exchange/online', async (req, res) => {
  const idToken = req.headers.authorization?.replace('Bearer ', '');
  let payload;
  try {
    payload = validateIdToken(idToken);
  } catch {
    // Signal App Bridge to fetch a fresh ID token and retry this request once.
    res.set('X-Shopify-Retry-Invalid-Session-Request', '1');
    return res.status(401).json({error: 'Invalid ID token'});
  }


  const shop = new URL(payload.dest).hostname;


  const response = await shopifyFetch(
    `https://${shop}/admin/oauth/access_token`,
    {
      method: 'POST',
      headers: {'Content-Type': 'application/x-www-form-urlencoded'},
      body: new URLSearchParams({
        client_id: SHOPIFY_CLIENT_ID,
        client_secret: SHOPIFY_CLIENT_SECRET,
        grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
        subject_token: idToken,
        subject_token_type: 'urn:ietf:params:oauth:token-type:id_token',
        requested_token_type:
          'urn:shopify:params:oauth:token-type:online-access-token',
      }),
    },
  );


  // Same as the offline route: a 400 means the ID token is stale, which a fresh
  // one fixes. Don't dress a retryable condition up as a server error.
  if (response.status === 400) {
    res.set('X-Shopify-Retry-Invalid-Session-Request', '1');
    return res.status(401).json({error: 'Invalid ID token'});
  }


  if (!response.ok) {
    return res.status(502).json({error: 'Token exchange failed'});
  }


  const {access_token, scope, expires_in} = await response.json();


  // Online tokens are scoped to the staff member who authorized them, so key
  // them by user (the ID token's `sub`) — not just by shop. Storing under a
  // shop-only key would let one staff member's token overwrite another's.
  // Track expiry so a lapsed token is dropped rather than sent.
  tokenStore[`${shop}:online:${payload.sub}`] = {
    access_token,
    expires_at: expiresAtFrom(expires_in),
  };


  // This app authorizes Admin API calls per staff member for this shop from here
  // on. /api/shop reads this and mints a replacement online token when one is
  // missing, rather than borrowing the shop-wide offline token.
  perUserAuthorization.add(shop);


  res.json({scope});
});


// Re-run token exchange for a token Shopify rejected, using the ID token that
// came with the current request. Same request as /exchange/offline and
// /exchange/online, minting whichever kind was rejected. The return value tells
// the caller how to react:
//   'minted'           — stored a new access token
//   'invalid_id_token' — Shopify returned 400; the ID token is stale, and a fresh
//                        one fixes it
//   'failed'           — anything else; retrying with the same inputs won't help
async function remintAccessToken({idToken, shop, sub, online}) {
  const response = await shopifyFetch(
    `https://${shop}/admin/oauth/access_token`,
    {
      method: 'POST',
      headers: {'Content-Type': 'application/x-www-form-urlencoded'},
      body: new URLSearchParams({
        client_id: SHOPIFY_CLIENT_ID,
        client_secret: SHOPIFY_CLIENT_SECRET,
        grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
        subject_token: idToken,
        subject_token_type: 'urn:ietf:params:oauth:token-type:id_token',
        requested_token_type: online
          ? 'urn:shopify:params:oauth:token-type:online-access-token'
          : 'urn:shopify:params:oauth:token-type:offline-access-token',
        // `expiring` only applies to offline tokens. An online token already
        // expires with the staff member's session.
        ...(online ? {} : {expiring: '1'}),
      }),
    },
  );


  if (response.status === 400) return 'invalid_id_token';
  if (!response.ok) return 'failed';


  const {access_token, refresh_token, expires_in} = await response.json();


  if (online) {
    tokenStore[`${shop}:online:${sub}`] = {
      access_token,
      expires_at: expiresAtFrom(expires_in),
    };
  } else {
    tokenStore[shop] = {
      access_token,
      refresh_token,
      expires_at: expiresAtFrom(expires_in),
    };
  }


  return 'minted';
}


app.get('/api/shop', async (req, res) => {
  const idToken = req.headers.authorization?.replace('Bearer ', '');
  let payload;
  try {
    payload = validateIdToken(idToken);
  } catch {
    // Signal App Bridge to fetch a fresh ID token and retry this request once.
    res.set('X-Shopify-Retry-Invalid-Session-Request', '1');
    return res.status(401).json({error: 'Invalid ID token'});
  }


  const shop = new URL(payload.dest).hostname;
  const onlineKey = `${shop}:online:${payload.sub}`;


  // Drop an expired online token rather than sending a dead credential. Online
  // tokens can't be refreshed — a new one is minted from a fresh ID token.
  const online = tokenStore[onlineKey];
  if (online?.expires_at && online.expires_at <= Date.now()) {
    delete tokenStore[onlineKey];
  }


  // Which token authorizes this call is decided by how the app is built, not by
  // what happens to be in the store. Deciding it by availability instead — the
  // `tokenStore[onlineKey] ?? tokenStore[shop]` this used to do — means that
  // whenever a staff member's online token is missing or expired, and online
  // tokens expire at logout or after 24 hours, their request quietly goes out
  // under the app's shop-wide offline token with the app's full scopes. The
  // Admin API then enforces nothing about that user, so a staff member without
  // permission for an action gets it anyway.
  const usingOnline = perUserAuthorization.has(shop);


  // Per-user app, no usable online token for this staff member: mint one from
  // the ID token this request already carries. This is the recovery the fallback
  // was standing in for, and it costs one token exchange.
  if (usingOnline && !tokenStore[onlineKey]) {
    const result = await remintAccessToken({
      idToken,
      shop,
      sub: payload.sub,
      online: true,
    });


    if (result === 'invalid_id_token') {
      res.set('X-Shopify-Retry-Invalid-Session-Request', '1');
      return res.status(401).json({error: 'Invalid ID token'});
    }
    if (result !== 'minted') {
      return res.status(502).json({error: 'Token exchange failed'});
    }
  }


  // Only the offline token is refreshable (online tokens are re-minted from a
  // fresh ID token via /exchange/online). Refresh it ~60 seconds before it
  // expires — but only when we're about to use it, so a still-valid online token
  // isn't blocked by a failed offline refresh.
  if (!usingOnline) {
    const offline = tokenStore[shop];
    if (offline?.expires_at && Date.now() >= offline.expires_at - 60 * 1000) {
      const result = await refreshOfflineToken(shop);
      if (result === 'reauthorize') {
        // No retry header here: the offline refresh token is dead, and a fresh
        // ID token can't revive it. The client must re-run /exchange/offline
        // (or reinstall) rather than retry this request into another 401.
        return res.status(401).json({error: 'reauthenticate'});
      }
      if (result === 'retry') {
        return res.status(503).json({error: 'Token refresh failed, try again'});
      }
      if (result === 'failed') {
        // Not transient and not the merchant's problem. Don't fall through — the
        // stored token is about to expire, so the request below would go out with
        // a credential that's already lapsing.
        return res.status(502).json({error: 'Token refresh rejected'});
      }
    }
  }


  // No `??` here on purpose: each mode reads only its own token, so a missing
  // one is a 401 rather than a silent upgrade to broader access.
  const stored = usingOnline ? tokenStore[onlineKey] : tokenStore[shop];
  if (!stored) return res.status(401).json({error: 'Not authenticated'});


  const callAdminApi = (accessToken) =>
    shopifyFetch(`https://${shop}/admin/api/2026-04/graphql.json`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-Shopify-Access-Token': accessToken,
      },
      body: JSON.stringify({query: '{ shop { name } }'}),
    });


  let response = await callAdminApi(stored.access_token);


  // Shopify rejected the access token (revoked, or the app's scopes changed).
  // Evict it and mint a replacement with token exchange: this request already
  // carries a validated ID token, so nothing the merchant does is needed.
  //
  // Don't answer this with X-Shopify-Retry-Invalid-Session-Request. That header
  // only makes App Bridge fetch a fresh *ID* token and replay this request — it
  // never re-runs the exchange, so the replay would find no stored token and fail
  // with "Not authenticated". It's the right answer for a rejected ID token, and
  // the wrong one for a rejected access token.
  if (response.status === 401) {
    const evictKey = usingOnline ? onlineKey : shop;
    delete tokenStore[evictKey];


    const result = await remintAccessToken({
      idToken,
      shop,
      sub: payload.sub,
      online: usingOnline,
    });


    // Only now is the retry header correct: the ID token itself is stale, and a
    // fresh one lets the replayed request mint a token successfully.
    if (result === 'invalid_id_token') {
      res.set('X-Shopify-Retry-Invalid-Session-Request', '1');
      return res.status(401).json({error: 'Invalid ID token'});
    }
    if (result !== 'minted') {
      return res.status(502).json({error: 'Token exchange failed'});
    }


    response = await callAdminApi(tokenStore[evictKey].access_token);


    // Retry once, not in a loop. A freshly minted token that's also rejected
    // means something is wrong beyond a stale credential, so stop and tell the
    // merchant to reauthorize rather than mint tokens indefinitely.
    if (response.status === 401) {
      delete tokenStore[evictKey];
      return res.status(401).json({error: 'reauthenticate'});
    }
  }


  // Forward Shopify's status. Answering a rate limit or an outage with a 200 and
  // an error body in it would tell the client the request succeeded.
  res.status(response.status).json(await response.json());
});


app.post('/refresh', async (req, res) => {
  // This endpoint mints a new access token, so authenticate the caller first.
  // Background callers (webhooks, scheduled jobs) have no session to produce an
  // ID token, so they send a shared secret in the `X-Refresh-Secret` header.
  if (!isAuthorizedTask(req)) {
    return res.status(401).json({error: 'Unauthorized'});
  }


  // Background callers supply the shop domain directly, since they have no
  // active session to derive it from an ID token. Defaulting to {} keeps a
  // request with no body — or the wrong content type — on the documented 400
  // path: Express 5 leaves req.body undefined when nothing was parsed, so
  // destructuring it directly would throw and return 500.
  const {shop} = req.body ?? {};
  if (!shop) return res.status(400).json({error: 'Missing shop'});


  // A 401 is terminal (expired, revoked, replayed outside the retry window, or
  // the app was uninstalled): re-authenticate the next time a merchant opens the
  // app. Other failures (network, 5xx, 429) are transient and safe to retry.
  const result = await refreshOfflineToken(shop);
  if (result === 'reauthorize') {
    return res.status(401).json({error: 'reauthenticate'});
  }
  if (result === 'retry') {
    return res.status(502).json({error: 'Token refresh failed'});
  }
  if (result === 'failed') {
    // Not transient: retrying sends the identical request. Answering with
    // success here would tell the client it holds a fresh token when it doesn't.
    return res.status(502).json({error: 'Token refresh rejected'});
  }


  res.json({success: true});
});


// express.json() throws on a malformed body, and Express's default error handler
// answers with an HTML page containing a stack trace and absolute file paths. This
// is a JSON API, so answer in JSON. Match only parse failures: anything else should
// keep surfacing loudly rather than be swallowed here.
app.use((err, req, res, next) => {
  if (err?.type === 'entity.parse.failed') {
    return res.status(400).json({error: 'Malformed JSON body'});
  }
  // The request never reached Shopify, so nothing was consumed and the caller can
  // try again. 503 says that; the stack trace Express would otherwise return says
  // the app is broken.
  if (err instanceof ShopifyUnreachable) {
    return res.status(503).json({error: 'Could not reach Shopify, try again'});
  }
  next(err);
});


app.listen(3000, () => console.log('Server running on http://localhost:3000'));
```

## Refresh an expiring offline token

Use this flow when your access token expires and no merchant session is available, such as for webhooks, scheduled jobs, and other background work. Without an active session, you can't get a fresh ID token from App Bridge, so you use the stored `refresh_token` instead.

Store the `refresh_token` from the exchange response and use it before `expires_in` seconds elapse. Refresh tokens are one-time-use and expire after 90 days.

Background refreshes usually run in-process from your scheduler or job runner, so there's no endpoint to secure. If you do expose one over HTTP, protect it with your own internal credential, such as a shared secret.

### Send the refresh request

```http
POST https://{shop}.myshopify.com/admin/oauth/access_token
```

| Parameter | Description |
| - | - |
| `client_id`required | The client ID for the app. |
| `client_secret`required | The client secret for the app. |
| `grant_type`required | The value `refresh_token` indicates that a refresh token grant is being used. |
| `refresh_token`required | The refresh token received when the access token was issued. |

Shopify returns a new access token and a new refresh token. A few things to keep in mind:

* Each refresh issues a new refresh token with a new 90-day expiration. Store it and discard the old one.
* The previous access token stays valid until its `expires_in` duration ends, but use the new token for all new requests.
* Token refreshes are resilient to ambiguous failures. If a refresh request times out, hits a network error, or returns a transient `5xx`, retry promptly with the same `refresh_token`. The repeated request returns the same rotated credentials rather than issuing another set. Treat a successful rotation as consuming the previous refresh token, and don't rely on a fixed retry-window duration.

When a refresh token can no longer be used, Shopify returns `401 Unauthorized` with `{"error": "invalid_request"}` and the description `This request requires an active refresh_token`. Shopify returns this same response for every terminal case, including an unknown token, a token replayed after the retry window, an expired token, and a revoked or uninstalled app, so don't branch on which one it was.

Treat that `401` as final: stop retrying, and re-authenticate by running token exchange the next time a merchant opens your app. Transient failures, such as network errors, timeouts, `5xx` responses, and `429` responses, are safe to retry with the same `refresh_token`.

Refreshing an access token doesn't change your client secret. Rotating your client secret is a separate operation that invalidates tokens tied to the old secret and requires migrating every store. See [Rotate your client secret](https://shopify.dev/docs/apps/build/authentication-authorization/manage-credentials#rotate-your-client-secret).

## /node/index.js

```javascript
import 'dotenv/config';
import express from 'express';
import path from 'path';
import {readFileSync} from 'fs';
import {fileURLToPath} from 'url';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';


const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express();
app.use(express.json());


const {SHOPIFY_CLIENT_ID, SHOPIFY_CLIENT_SECRET, REFRESH_TASK_SECRET} =
  process.env;


// Inject the App Bridge API key (your client ID) into index.html before serving
// it. express.static would return the file verbatim, leaving the literal
// %SHOPIFY_API_KEY% placeholder in the page — so App Bridge never initializes.
app.get(['/', '/index.html'], (req, res) => {
  const html = readFileSync(
    path.join(__dirname, '..', 'public', 'index.html'),
    'utf8',
  ).replace('%SHOPIFY_API_KEY%', SHOPIFY_CLIENT_ID);
  res.type('html').send(html);
});


app.use(express.static(path.join(__dirname, '..', 'public')));


// In-memory token store (use a persistent session store in production)
const tokenStore = {};


// Shops whose Admin API calls must run under the acting staff member's own
// online token. A real app knows this statically — it's a property of how the
// app is built, not of what's in the store. This sample records it the first
// time /exchange/online succeeds so both flows stay demonstrable.
//
// The point of tracking it at all: once an app needs per-user authorization,
// a missing online token is a condition to recover from, never a reason to
// reach for the shop-wide offline token. That substitution would run a
// low-privileged staff member's request with the app's full access.
const perUserAuthorization = new Set();


// A valid expiring-token response includes expires_in (seconds until the access
// token expires). Return null when it's absent or non-positive: treat the token
// as non-expiring and never refresh it. Storing Date.now() instead would make
// the next request refresh a token that has no refresh_token — a permanent 401.
function expiresAtFrom(expiresIn) {
  const seconds = Number(expiresIn);
  return seconds > 0 ? Date.now() + seconds * 1000 : null;
}


// Node's fetch has no timeout, so a stalled connection to Shopify would hang a
// request until the client gives up. Give every call a deadline, and tag transport
// failures so callers can tell "Shopify said no" from "we never reached Shopify".
// fetch rejects only on a transport failure or this timeout: every HTTP status,
// including 5xx, resolves and is the caller's to handle.
const SHOPIFY_TIMEOUT_MS = 30_000;


class ShopifyUnreachable extends Error {}


async function shopifyFetch(url, options) {
  try {
    return await fetch(url, {
      ...options,
      signal: AbortSignal.timeout(SHOPIFY_TIMEOUT_MS),
    });
  } catch (cause) {
    throw new ShopifyUnreachable(`Could not reach ${new URL(url).hostname}`, {
      cause,
    });
  }
}


function validateIdToken(idToken) {
  const payload = jwt.verify(idToken, SHOPIFY_CLIENT_SECRET, {
    algorithms: ['HS256'],
    audience: SHOPIFY_CLIENT_ID,
  });


  const issuerHost = new URL(payload.iss).hostname;
  const destHost = new URL(payload.dest).hostname;
  if (issuerHost !== destHost) {
    throw new Error('Token issuer and destination do not match');
  }


  return payload;
}


// Background callers (webhooks, scheduled jobs) have no session to produce an
// ID token, so they authenticate with a shared secret that only your own
// backend and schedulers know. It's sent in the `X-Refresh-Secret` header.
function isAuthorizedTask(req) {
  const provided = req.get('X-Refresh-Secret') ?? '';
  if (!REFRESH_TASK_SECRET || !provided) return false;
  const a = Buffer.from(provided);
  const b = Buffer.from(REFRESH_TASK_SECRET);
  // timingSafeEqual throws on length mismatch, so check length first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}


// Exchange the stored offline refresh token for a new offline access token.
// The return value tells the caller how to react:
//   'refreshed'   — stored a new access token
//   'reauthorize' — no refresh token, or Shopify returned 401 (the refresh token
//                   is expired, revoked, replayed outside the retry window, or the
//                   app was uninstalled); the merchant must reinstall
//   'retry'       — a transient failure (network, 5xx, 429); safe to retry later
//   'failed'      — any other non-OK status, such as a malformed request or bad
//                   client credentials; the same request fails the same way, so
//                   surface it instead of hiding it behind the retry path
async function refreshOfflineToken(shop) {
  const stored = tokenStore[shop];
  if (!stored?.refresh_token) return 'reauthorize';


  let response;
  try {
    response = await shopifyFetch(`https://${shop}/admin/oauth/access_token`, {
      method: 'POST',
      headers: {'Content-Type': 'application/x-www-form-urlencoded'},
      body: new URLSearchParams({
        grant_type: 'refresh_token',
        client_id: SHOPIFY_CLIENT_ID,
        client_secret: SHOPIFY_CLIENT_SECRET,
        refresh_token: stored.refresh_token,
      }),
    });
  } catch (error) {
    // Only a transport failure or timeout becomes 'retry': the request never
    // reached Shopify, so the refresh token is untouched and a later attempt is
    // safe. Anything else is a bug in this code — let it surface.
    if (!(error instanceof ShopifyUnreachable)) throw error;
    return 'retry';
  }


  if (response.status === 401) {
    delete tokenStore[shop];
    return 'reauthorize';
  }
  // Only a rate limit or a server fault is worth retrying. Treating every other
  // non-OK status as transient would retry an unrecoverable refresh forever —
  // a 400 for a malformed body, or a 403 for bad client credentials, returns the
  // same response no matter how long you wait.
  if (response.status === 429 || response.status >= 500) return 'retry';
  if (!response.ok) return 'failed';


  const {access_token, refresh_token, expires_in} = await response.json();
  tokenStore[shop] = {
    access_token,
    refresh_token,
    expires_at: expiresAtFrom(expires_in),
  };
  return 'refreshed';
}


app.post('/exchange/offline', async (req, res) => {
  const idToken = req.headers.authorization?.replace('Bearer ', '');
  let payload;
  try {
    payload = validateIdToken(idToken);
  } catch {
    // Signal App Bridge to fetch a fresh ID token and retry this request once.
    res.set('X-Shopify-Retry-Invalid-Session-Request', '1');
    return res.status(401).json({error: 'Invalid ID token'});
  }


  const shop = new URL(payload.dest).hostname;


  const response = await shopifyFetch(
    `https://${shop}/admin/oauth/access_token`,
    {
      method: 'POST',
      headers: {'Content-Type': 'application/x-www-form-urlencoded'},
      body: new URLSearchParams({
        client_id: SHOPIFY_CLIENT_ID,
        client_secret: SHOPIFY_CLIENT_SECRET,
        grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
        subject_token: idToken,
        subject_token_type: 'urn:ietf:params:oauth:token-type:id_token',
        requested_token_type:
          'urn:shopify:params:oauth:token-type:offline-access-token',
        expiring: '1',
      }),
    },
  );


  // Shopify returns 400 when the ID token is expired or otherwise invalid. ID
  // tokens live about a minute, so that's a routine client condition, not a server
  // fault — answer it like a local validation failure so App Bridge fetches a fresh
  // token and retries. Returning 502 would say the opposite: don't bother retrying.
  if (response.status === 400) {
    res.set('X-Shopify-Retry-Invalid-Session-Request', '1');
    return res.status(401).json({error: 'Invalid ID token'});
  }


  if (!response.ok) {
    return res.status(502).json({error: 'Token exchange failed'});
  }


  const {access_token, refresh_token, scope, expires_in} = await response.json();


  // Store tokens server-side — never send them to the browser. Track expiry so
  // requests can refresh the offline token before it lapses.
  tokenStore[shop] = {
    access_token,
    refresh_token,
    expires_at: expiresAtFrom(expires_in),
  };


  res.json({scope});
});


app.post('/exchange/online', async (req, res) => {
  const idToken = req.headers.authorization?.replace('Bearer ', '');
  let payload;
  try {
    payload = validateIdToken(idToken);
  } catch {
    // Signal App Bridge to fetch a fresh ID token and retry this request once.
    res.set('X-Shopify-Retry-Invalid-Session-Request', '1');
    return res.status(401).json({error: 'Invalid ID token'});
  }


  const shop = new URL(payload.dest).hostname;


  const response = await shopifyFetch(
    `https://${shop}/admin/oauth/access_token`,
    {
      method: 'POST',
      headers: {'Content-Type': 'application/x-www-form-urlencoded'},
      body: new URLSearchParams({
        client_id: SHOPIFY_CLIENT_ID,
        client_secret: SHOPIFY_CLIENT_SECRET,
        grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
        subject_token: idToken,
        subject_token_type: 'urn:ietf:params:oauth:token-type:id_token',
        requested_token_type:
          'urn:shopify:params:oauth:token-type:online-access-token',
      }),
    },
  );


  // Same as the offline route: a 400 means the ID token is stale, which a fresh
  // one fixes. Don't dress a retryable condition up as a server error.
  if (response.status === 400) {
    res.set('X-Shopify-Retry-Invalid-Session-Request', '1');
    return res.status(401).json({error: 'Invalid ID token'});
  }


  if (!response.ok) {
    return res.status(502).json({error: 'Token exchange failed'});
  }


  const {access_token, scope, expires_in} = await response.json();


  // Online tokens are scoped to the staff member who authorized them, so key
  // them by user (the ID token's `sub`) — not just by shop. Storing under a
  // shop-only key would let one staff member's token overwrite another's.
  // Track expiry so a lapsed token is dropped rather than sent.
  tokenStore[`${shop}:online:${payload.sub}`] = {
    access_token,
    expires_at: expiresAtFrom(expires_in),
  };


  // This app authorizes Admin API calls per staff member for this shop from here
  // on. /api/shop reads this and mints a replacement online token when one is
  // missing, rather than borrowing the shop-wide offline token.
  perUserAuthorization.add(shop);


  res.json({scope});
});


// Re-run token exchange for a token Shopify rejected, using the ID token that
// came with the current request. Same request as /exchange/offline and
// /exchange/online, minting whichever kind was rejected. The return value tells
// the caller how to react:
//   'minted'           — stored a new access token
//   'invalid_id_token' — Shopify returned 400; the ID token is stale, and a fresh
//                        one fixes it
//   'failed'           — anything else; retrying with the same inputs won't help
async function remintAccessToken({idToken, shop, sub, online}) {
  const response = await shopifyFetch(
    `https://${shop}/admin/oauth/access_token`,
    {
      method: 'POST',
      headers: {'Content-Type': 'application/x-www-form-urlencoded'},
      body: new URLSearchParams({
        client_id: SHOPIFY_CLIENT_ID,
        client_secret: SHOPIFY_CLIENT_SECRET,
        grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
        subject_token: idToken,
        subject_token_type: 'urn:ietf:params:oauth:token-type:id_token',
        requested_token_type: online
          ? 'urn:shopify:params:oauth:token-type:online-access-token'
          : 'urn:shopify:params:oauth:token-type:offline-access-token',
        // `expiring` only applies to offline tokens. An online token already
        // expires with the staff member's session.
        ...(online ? {} : {expiring: '1'}),
      }),
    },
  );


  if (response.status === 400) return 'invalid_id_token';
  if (!response.ok) return 'failed';


  const {access_token, refresh_token, expires_in} = await response.json();


  if (online) {
    tokenStore[`${shop}:online:${sub}`] = {
      access_token,
      expires_at: expiresAtFrom(expires_in),
    };
  } else {
    tokenStore[shop] = {
      access_token,
      refresh_token,
      expires_at: expiresAtFrom(expires_in),
    };
  }


  return 'minted';
}


app.get('/api/shop', async (req, res) => {
  const idToken = req.headers.authorization?.replace('Bearer ', '');
  let payload;
  try {
    payload = validateIdToken(idToken);
  } catch {
    // Signal App Bridge to fetch a fresh ID token and retry this request once.
    res.set('X-Shopify-Retry-Invalid-Session-Request', '1');
    return res.status(401).json({error: 'Invalid ID token'});
  }


  const shop = new URL(payload.dest).hostname;
  const onlineKey = `${shop}:online:${payload.sub}`;


  // Drop an expired online token rather than sending a dead credential. Online
  // tokens can't be refreshed — a new one is minted from a fresh ID token.
  const online = tokenStore[onlineKey];
  if (online?.expires_at && online.expires_at <= Date.now()) {
    delete tokenStore[onlineKey];
  }


  // Which token authorizes this call is decided by how the app is built, not by
  // what happens to be in the store. Deciding it by availability instead — the
  // `tokenStore[onlineKey] ?? tokenStore[shop]` this used to do — means that
  // whenever a staff member's online token is missing or expired, and online
  // tokens expire at logout or after 24 hours, their request quietly goes out
  // under the app's shop-wide offline token with the app's full scopes. The
  // Admin API then enforces nothing about that user, so a staff member without
  // permission for an action gets it anyway.
  const usingOnline = perUserAuthorization.has(shop);


  // Per-user app, no usable online token for this staff member: mint one from
  // the ID token this request already carries. This is the recovery the fallback
  // was standing in for, and it costs one token exchange.
  if (usingOnline && !tokenStore[onlineKey]) {
    const result = await remintAccessToken({
      idToken,
      shop,
      sub: payload.sub,
      online: true,
    });


    if (result === 'invalid_id_token') {
      res.set('X-Shopify-Retry-Invalid-Session-Request', '1');
      return res.status(401).json({error: 'Invalid ID token'});
    }
    if (result !== 'minted') {
      return res.status(502).json({error: 'Token exchange failed'});
    }
  }


  // Only the offline token is refreshable (online tokens are re-minted from a
  // fresh ID token via /exchange/online). Refresh it ~60 seconds before it
  // expires — but only when we're about to use it, so a still-valid online token
  // isn't blocked by a failed offline refresh.
  if (!usingOnline) {
    const offline = tokenStore[shop];
    if (offline?.expires_at && Date.now() >= offline.expires_at - 60 * 1000) {
      const result = await refreshOfflineToken(shop);
      if (result === 'reauthorize') {
        // No retry header here: the offline refresh token is dead, and a fresh
        // ID token can't revive it. The client must re-run /exchange/offline
        // (or reinstall) rather than retry this request into another 401.
        return res.status(401).json({error: 'reauthenticate'});
      }
      if (result === 'retry') {
        return res.status(503).json({error: 'Token refresh failed, try again'});
      }
      if (result === 'failed') {
        // Not transient and not the merchant's problem. Don't fall through — the
        // stored token is about to expire, so the request below would go out with
        // a credential that's already lapsing.
        return res.status(502).json({error: 'Token refresh rejected'});
      }
    }
  }


  // No `??` here on purpose: each mode reads only its own token, so a missing
  // one is a 401 rather than a silent upgrade to broader access.
  const stored = usingOnline ? tokenStore[onlineKey] : tokenStore[shop];
  if (!stored) return res.status(401).json({error: 'Not authenticated'});


  const callAdminApi = (accessToken) =>
    shopifyFetch(`https://${shop}/admin/api/2026-04/graphql.json`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-Shopify-Access-Token': accessToken,
      },
      body: JSON.stringify({query: '{ shop { name } }'}),
    });


  let response = await callAdminApi(stored.access_token);


  // Shopify rejected the access token (revoked, or the app's scopes changed).
  // Evict it and mint a replacement with token exchange: this request already
  // carries a validated ID token, so nothing the merchant does is needed.
  //
  // Don't answer this with X-Shopify-Retry-Invalid-Session-Request. That header
  // only makes App Bridge fetch a fresh *ID* token and replay this request — it
  // never re-runs the exchange, so the replay would find no stored token and fail
  // with "Not authenticated". It's the right answer for a rejected ID token, and
  // the wrong one for a rejected access token.
  if (response.status === 401) {
    const evictKey = usingOnline ? onlineKey : shop;
    delete tokenStore[evictKey];


    const result = await remintAccessToken({
      idToken,
      shop,
      sub: payload.sub,
      online: usingOnline,
    });


    // Only now is the retry header correct: the ID token itself is stale, and a
    // fresh one lets the replayed request mint a token successfully.
    if (result === 'invalid_id_token') {
      res.set('X-Shopify-Retry-Invalid-Session-Request', '1');
      return res.status(401).json({error: 'Invalid ID token'});
    }
    if (result !== 'minted') {
      return res.status(502).json({error: 'Token exchange failed'});
    }


    response = await callAdminApi(tokenStore[evictKey].access_token);


    // Retry once, not in a loop. A freshly minted token that's also rejected
    // means something is wrong beyond a stale credential, so stop and tell the
    // merchant to reauthorize rather than mint tokens indefinitely.
    if (response.status === 401) {
      delete tokenStore[evictKey];
      return res.status(401).json({error: 'reauthenticate'});
    }
  }


  // Forward Shopify's status. Answering a rate limit or an outage with a 200 and
  // an error body in it would tell the client the request succeeded.
  res.status(response.status).json(await response.json());
});


app.post('/refresh', async (req, res) => {
  // This endpoint mints a new access token, so authenticate the caller first.
  // Background callers (webhooks, scheduled jobs) have no session to produce an
  // ID token, so they send a shared secret in the `X-Refresh-Secret` header.
  if (!isAuthorizedTask(req)) {
    return res.status(401).json({error: 'Unauthorized'});
  }


  // Background callers supply the shop domain directly, since they have no
  // active session to derive it from an ID token. Defaulting to {} keeps a
  // request with no body — or the wrong content type — on the documented 400
  // path: Express 5 leaves req.body undefined when nothing was parsed, so
  // destructuring it directly would throw and return 500.
  const {shop} = req.body ?? {};
  if (!shop) return res.status(400).json({error: 'Missing shop'});


  // A 401 is terminal (expired, revoked, replayed outside the retry window, or
  // the app was uninstalled): re-authenticate the next time a merchant opens the
  // app. Other failures (network, 5xx, 429) are transient and safe to retry.
  const result = await refreshOfflineToken(shop);
  if (result === 'reauthorize') {
    return res.status(401).json({error: 'reauthenticate'});
  }
  if (result === 'retry') {
    return res.status(502).json({error: 'Token refresh failed'});
  }
  if (result === 'failed') {
    // Not transient: retrying sends the identical request. Answering with
    // success here would tell the client it holds a fresh token when it doesn't.
    return res.status(502).json({error: 'Token refresh rejected'});
  }


  res.json({success: true});
});


// express.json() throws on a malformed body, and Express's default error handler
// answers with an HTML page containing a stack trace and absolute file paths. This
// is a JSON API, so answer in JSON. Match only parse failures: anything else should
// keep surfacing loudly rather than be swallowed here.
app.use((err, req, res, next) => {
  if (err?.type === 'entity.parse.failed') {
    return res.status(400).json({error: 'Malformed JSON body'});
  }
  // The request never reached Shopify, so nothing was consumed and the caller can
  // try again. 503 says that; the stack trace Express would otherwise return says
  // the app is broken.
  if (err instanceof ShopifyUnreachable) {
    return res.status(503).json({error: 'Could not reach Shopify, try again'});
  }
  next(err);
});


app.listen(3000, () => console.log('Server running on http://localhost:3000'));
```

## /public/app.js

```javascript
const idToken = await shopify.idToken();


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

## /node/index.js

```javascript
import 'dotenv/config';
import express from 'express';
import path from 'path';
import {readFileSync} from 'fs';
import {fileURLToPath} from 'url';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';


const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express();
app.use(express.json());


const {SHOPIFY_CLIENT_ID, SHOPIFY_CLIENT_SECRET, REFRESH_TASK_SECRET} =
  process.env;


// Inject the App Bridge API key (your client ID) into index.html before serving
// it. express.static would return the file verbatim, leaving the literal
// %SHOPIFY_API_KEY% placeholder in the page — so App Bridge never initializes.
app.get(['/', '/index.html'], (req, res) => {
  const html = readFileSync(
    path.join(__dirname, '..', 'public', 'index.html'),
    'utf8',
  ).replace('%SHOPIFY_API_KEY%', SHOPIFY_CLIENT_ID);
  res.type('html').send(html);
});


app.use(express.static(path.join(__dirname, '..', 'public')));


// In-memory token store (use a persistent session store in production)
const tokenStore = {};


// Shops whose Admin API calls must run under the acting staff member's own
// online token. A real app knows this statically — it's a property of how the
// app is built, not of what's in the store. This sample records it the first
// time /exchange/online succeeds so both flows stay demonstrable.
//
// The point of tracking it at all: once an app needs per-user authorization,
// a missing online token is a condition to recover from, never a reason to
// reach for the shop-wide offline token. That substitution would run a
// low-privileged staff member's request with the app's full access.
const perUserAuthorization = new Set();


// A valid expiring-token response includes expires_in (seconds until the access
// token expires). Return null when it's absent or non-positive: treat the token
// as non-expiring and never refresh it. Storing Date.now() instead would make
// the next request refresh a token that has no refresh_token — a permanent 401.
function expiresAtFrom(expiresIn) {
  const seconds = Number(expiresIn);
  return seconds > 0 ? Date.now() + seconds * 1000 : null;
}


// Node's fetch has no timeout, so a stalled connection to Shopify would hang a
// request until the client gives up. Give every call a deadline, and tag transport
// failures so callers can tell "Shopify said no" from "we never reached Shopify".
// fetch rejects only on a transport failure or this timeout: every HTTP status,
// including 5xx, resolves and is the caller's to handle.
const SHOPIFY_TIMEOUT_MS = 30_000;


class ShopifyUnreachable extends Error {}


async function shopifyFetch(url, options) {
  try {
    return await fetch(url, {
      ...options,
      signal: AbortSignal.timeout(SHOPIFY_TIMEOUT_MS),
    });
  } catch (cause) {
    throw new ShopifyUnreachable(`Could not reach ${new URL(url).hostname}`, {
      cause,
    });
  }
}


function validateIdToken(idToken) {
  const payload = jwt.verify(idToken, SHOPIFY_CLIENT_SECRET, {
    algorithms: ['HS256'],
    audience: SHOPIFY_CLIENT_ID,
  });


  const issuerHost = new URL(payload.iss).hostname;
  const destHost = new URL(payload.dest).hostname;
  if (issuerHost !== destHost) {
    throw new Error('Token issuer and destination do not match');
  }


  return payload;
}


// Background callers (webhooks, scheduled jobs) have no session to produce an
// ID token, so they authenticate with a shared secret that only your own
// backend and schedulers know. It's sent in the `X-Refresh-Secret` header.
function isAuthorizedTask(req) {
  const provided = req.get('X-Refresh-Secret') ?? '';
  if (!REFRESH_TASK_SECRET || !provided) return false;
  const a = Buffer.from(provided);
  const b = Buffer.from(REFRESH_TASK_SECRET);
  // timingSafeEqual throws on length mismatch, so check length first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}


// Exchange the stored offline refresh token for a new offline access token.
// The return value tells the caller how to react:
//   'refreshed'   — stored a new access token
//   'reauthorize' — no refresh token, or Shopify returned 401 (the refresh token
//                   is expired, revoked, replayed outside the retry window, or the
//                   app was uninstalled); the merchant must reinstall
//   'retry'       — a transient failure (network, 5xx, 429); safe to retry later
//   'failed'      — any other non-OK status, such as a malformed request or bad
//                   client credentials; the same request fails the same way, so
//                   surface it instead of hiding it behind the retry path
async function refreshOfflineToken(shop) {
  const stored = tokenStore[shop];
  if (!stored?.refresh_token) return 'reauthorize';


  let response;
  try {
    response = await shopifyFetch(`https://${shop}/admin/oauth/access_token`, {
      method: 'POST',
      headers: {'Content-Type': 'application/x-www-form-urlencoded'},
      body: new URLSearchParams({
        grant_type: 'refresh_token',
        client_id: SHOPIFY_CLIENT_ID,
        client_secret: SHOPIFY_CLIENT_SECRET,
        refresh_token: stored.refresh_token,
      }),
    });
  } catch (error) {
    // Only a transport failure or timeout becomes 'retry': the request never
    // reached Shopify, so the refresh token is untouched and a later attempt is
    // safe. Anything else is a bug in this code — let it surface.
    if (!(error instanceof ShopifyUnreachable)) throw error;
    return 'retry';
  }


  if (response.status === 401) {
    delete tokenStore[shop];
    return 'reauthorize';
  }
  // Only a rate limit or a server fault is worth retrying. Treating every other
  // non-OK status as transient would retry an unrecoverable refresh forever —
  // a 400 for a malformed body, or a 403 for bad client credentials, returns the
  // same response no matter how long you wait.
  if (response.status === 429 || response.status >= 500) return 'retry';
  if (!response.ok) return 'failed';


  const {access_token, refresh_token, expires_in} = await response.json();
  tokenStore[shop] = {
    access_token,
    refresh_token,
    expires_at: expiresAtFrom(expires_in),
  };
  return 'refreshed';
}


app.post('/exchange/offline', async (req, res) => {
  const idToken = req.headers.authorization?.replace('Bearer ', '');
  let payload;
  try {
    payload = validateIdToken(idToken);
  } catch {
    // Signal App Bridge to fetch a fresh ID token and retry this request once.
    res.set('X-Shopify-Retry-Invalid-Session-Request', '1');
    return res.status(401).json({error: 'Invalid ID token'});
  }


  const shop = new URL(payload.dest).hostname;


  const response = await shopifyFetch(
    `https://${shop}/admin/oauth/access_token`,
    {
      method: 'POST',
      headers: {'Content-Type': 'application/x-www-form-urlencoded'},
      body: new URLSearchParams({
        client_id: SHOPIFY_CLIENT_ID,
        client_secret: SHOPIFY_CLIENT_SECRET,
        grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
        subject_token: idToken,
        subject_token_type: 'urn:ietf:params:oauth:token-type:id_token',
        requested_token_type:
          'urn:shopify:params:oauth:token-type:offline-access-token',
        expiring: '1',
      }),
    },
  );


  // Shopify returns 400 when the ID token is expired or otherwise invalid. ID
  // tokens live about a minute, so that's a routine client condition, not a server
  // fault — answer it like a local validation failure so App Bridge fetches a fresh
  // token and retries. Returning 502 would say the opposite: don't bother retrying.
  if (response.status === 400) {
    res.set('X-Shopify-Retry-Invalid-Session-Request', '1');
    return res.status(401).json({error: 'Invalid ID token'});
  }


  if (!response.ok) {
    return res.status(502).json({error: 'Token exchange failed'});
  }


  const {access_token, refresh_token, scope, expires_in} = await response.json();


  // Store tokens server-side — never send them to the browser. Track expiry so
  // requests can refresh the offline token before it lapses.
  tokenStore[shop] = {
    access_token,
    refresh_token,
    expires_at: expiresAtFrom(expires_in),
  };


  res.json({scope});
});


app.post('/exchange/online', async (req, res) => {
  const idToken = req.headers.authorization?.replace('Bearer ', '');
  let payload;
  try {
    payload = validateIdToken(idToken);
  } catch {
    // Signal App Bridge to fetch a fresh ID token and retry this request once.
    res.set('X-Shopify-Retry-Invalid-Session-Request', '1');
    return res.status(401).json({error: 'Invalid ID token'});
  }


  const shop = new URL(payload.dest).hostname;


  const response = await shopifyFetch(
    `https://${shop}/admin/oauth/access_token`,
    {
      method: 'POST',
      headers: {'Content-Type': 'application/x-www-form-urlencoded'},
      body: new URLSearchParams({
        client_id: SHOPIFY_CLIENT_ID,
        client_secret: SHOPIFY_CLIENT_SECRET,
        grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
        subject_token: idToken,
        subject_token_type: 'urn:ietf:params:oauth:token-type:id_token',
        requested_token_type:
          'urn:shopify:params:oauth:token-type:online-access-token',
      }),
    },
  );


  // Same as the offline route: a 400 means the ID token is stale, which a fresh
  // one fixes. Don't dress a retryable condition up as a server error.
  if (response.status === 400) {
    res.set('X-Shopify-Retry-Invalid-Session-Request', '1');
    return res.status(401).json({error: 'Invalid ID token'});
  }


  if (!response.ok) {
    return res.status(502).json({error: 'Token exchange failed'});
  }


  const {access_token, scope, expires_in} = await response.json();


  // Online tokens are scoped to the staff member who authorized them, so key
  // them by user (the ID token's `sub`) — not just by shop. Storing under a
  // shop-only key would let one staff member's token overwrite another's.
  // Track expiry so a lapsed token is dropped rather than sent.
  tokenStore[`${shop}:online:${payload.sub}`] = {
    access_token,
    expires_at: expiresAtFrom(expires_in),
  };


  // This app authorizes Admin API calls per staff member for this shop from here
  // on. /api/shop reads this and mints a replacement online token when one is
  // missing, rather than borrowing the shop-wide offline token.
  perUserAuthorization.add(shop);


  res.json({scope});
});


// Re-run token exchange for a token Shopify rejected, using the ID token that
// came with the current request. Same request as /exchange/offline and
// /exchange/online, minting whichever kind was rejected. The return value tells
// the caller how to react:
//   'minted'           — stored a new access token
//   'invalid_id_token' — Shopify returned 400; the ID token is stale, and a fresh
//                        one fixes it
//   'failed'           — anything else; retrying with the same inputs won't help
async function remintAccessToken({idToken, shop, sub, online}) {
  const response = await shopifyFetch(
    `https://${shop}/admin/oauth/access_token`,
    {
      method: 'POST',
      headers: {'Content-Type': 'application/x-www-form-urlencoded'},
      body: new URLSearchParams({
        client_id: SHOPIFY_CLIENT_ID,
        client_secret: SHOPIFY_CLIENT_SECRET,
        grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
        subject_token: idToken,
        subject_token_type: 'urn:ietf:params:oauth:token-type:id_token',
        requested_token_type: online
          ? 'urn:shopify:params:oauth:token-type:online-access-token'
          : 'urn:shopify:params:oauth:token-type:offline-access-token',
        // `expiring` only applies to offline tokens. An online token already
        // expires with the staff member's session.
        ...(online ? {} : {expiring: '1'}),
      }),
    },
  );


  if (response.status === 400) return 'invalid_id_token';
  if (!response.ok) return 'failed';


  const {access_token, refresh_token, expires_in} = await response.json();


  if (online) {
    tokenStore[`${shop}:online:${sub}`] = {
      access_token,
      expires_at: expiresAtFrom(expires_in),
    };
  } else {
    tokenStore[shop] = {
      access_token,
      refresh_token,
      expires_at: expiresAtFrom(expires_in),
    };
  }


  return 'minted';
}


app.get('/api/shop', async (req, res) => {
  const idToken = req.headers.authorization?.replace('Bearer ', '');
  let payload;
  try {
    payload = validateIdToken(idToken);
  } catch {
    // Signal App Bridge to fetch a fresh ID token and retry this request once.
    res.set('X-Shopify-Retry-Invalid-Session-Request', '1');
    return res.status(401).json({error: 'Invalid ID token'});
  }


  const shop = new URL(payload.dest).hostname;
  const onlineKey = `${shop}:online:${payload.sub}`;


  // Drop an expired online token rather than sending a dead credential. Online
  // tokens can't be refreshed — a new one is minted from a fresh ID token.
  const online = tokenStore[onlineKey];
  if (online?.expires_at && online.expires_at <= Date.now()) {
    delete tokenStore[onlineKey];
  }


  // Which token authorizes this call is decided by how the app is built, not by
  // what happens to be in the store. Deciding it by availability instead — the
  // `tokenStore[onlineKey] ?? tokenStore[shop]` this used to do — means that
  // whenever a staff member's online token is missing or expired, and online
  // tokens expire at logout or after 24 hours, their request quietly goes out
  // under the app's shop-wide offline token with the app's full scopes. The
  // Admin API then enforces nothing about that user, so a staff member without
  // permission for an action gets it anyway.
  const usingOnline = perUserAuthorization.has(shop);


  // Per-user app, no usable online token for this staff member: mint one from
  // the ID token this request already carries. This is the recovery the fallback
  // was standing in for, and it costs one token exchange.
  if (usingOnline && !tokenStore[onlineKey]) {
    const result = await remintAccessToken({
      idToken,
      shop,
      sub: payload.sub,
      online: true,
    });


    if (result === 'invalid_id_token') {
      res.set('X-Shopify-Retry-Invalid-Session-Request', '1');
      return res.status(401).json({error: 'Invalid ID token'});
    }
    if (result !== 'minted') {
      return res.status(502).json({error: 'Token exchange failed'});
    }
  }


  // Only the offline token is refreshable (online tokens are re-minted from a
  // fresh ID token via /exchange/online). Refresh it ~60 seconds before it
  // expires — but only when we're about to use it, so a still-valid online token
  // isn't blocked by a failed offline refresh.
  if (!usingOnline) {
    const offline = tokenStore[shop];
    if (offline?.expires_at && Date.now() >= offline.expires_at - 60 * 1000) {
      const result = await refreshOfflineToken(shop);
      if (result === 'reauthorize') {
        // No retry header here: the offline refresh token is dead, and a fresh
        // ID token can't revive it. The client must re-run /exchange/offline
        // (or reinstall) rather than retry this request into another 401.
        return res.status(401).json({error: 'reauthenticate'});
      }
      if (result === 'retry') {
        return res.status(503).json({error: 'Token refresh failed, try again'});
      }
      if (result === 'failed') {
        // Not transient and not the merchant's problem. Don't fall through — the
        // stored token is about to expire, so the request below would go out with
        // a credential that's already lapsing.
        return res.status(502).json({error: 'Token refresh rejected'});
      }
    }
  }


  // No `??` here on purpose: each mode reads only its own token, so a missing
  // one is a 401 rather than a silent upgrade to broader access.
  const stored = usingOnline ? tokenStore[onlineKey] : tokenStore[shop];
  if (!stored) return res.status(401).json({error: 'Not authenticated'});


  const callAdminApi = (accessToken) =>
    shopifyFetch(`https://${shop}/admin/api/2026-04/graphql.json`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-Shopify-Access-Token': accessToken,
      },
      body: JSON.stringify({query: '{ shop { name } }'}),
    });


  let response = await callAdminApi(stored.access_token);


  // Shopify rejected the access token (revoked, or the app's scopes changed).
  // Evict it and mint a replacement with token exchange: this request already
  // carries a validated ID token, so nothing the merchant does is needed.
  //
  // Don't answer this with X-Shopify-Retry-Invalid-Session-Request. That header
  // only makes App Bridge fetch a fresh *ID* token and replay this request — it
  // never re-runs the exchange, so the replay would find no stored token and fail
  // with "Not authenticated". It's the right answer for a rejected ID token, and
  // the wrong one for a rejected access token.
  if (response.status === 401) {
    const evictKey = usingOnline ? onlineKey : shop;
    delete tokenStore[evictKey];


    const result = await remintAccessToken({
      idToken,
      shop,
      sub: payload.sub,
      online: usingOnline,
    });


    // Only now is the retry header correct: the ID token itself is stale, and a
    // fresh one lets the replayed request mint a token successfully.
    if (result === 'invalid_id_token') {
      res.set('X-Shopify-Retry-Invalid-Session-Request', '1');
      return res.status(401).json({error: 'Invalid ID token'});
    }
    if (result !== 'minted') {
      return res.status(502).json({error: 'Token exchange failed'});
    }


    response = await callAdminApi(tokenStore[evictKey].access_token);


    // Retry once, not in a loop. A freshly minted token that's also rejected
    // means something is wrong beyond a stale credential, so stop and tell the
    // merchant to reauthorize rather than mint tokens indefinitely.
    if (response.status === 401) {
      delete tokenStore[evictKey];
      return res.status(401).json({error: 'reauthenticate'});
    }
  }


  // Forward Shopify's status. Answering a rate limit or an outage with a 200 and
  // an error body in it would tell the client the request succeeded.
  res.status(response.status).json(await response.json());
});


app.post('/refresh', async (req, res) => {
  // This endpoint mints a new access token, so authenticate the caller first.
  // Background callers (webhooks, scheduled jobs) have no session to produce an
  // ID token, so they send a shared secret in the `X-Refresh-Secret` header.
  if (!isAuthorizedTask(req)) {
    return res.status(401).json({error: 'Unauthorized'});
  }


  // Background callers supply the shop domain directly, since they have no
  // active session to derive it from an ID token. Defaulting to {} keeps a
  // request with no body — or the wrong content type — on the documented 400
  // path: Express 5 leaves req.body undefined when nothing was parsed, so
  // destructuring it directly would throw and return 500.
  const {shop} = req.body ?? {};
  if (!shop) return res.status(400).json({error: 'Missing shop'});


  // A 401 is terminal (expired, revoked, replayed outside the retry window, or
  // the app was uninstalled): re-authenticate the next time a merchant opens the
  // app. Other failures (network, 5xx, 429) are transient and safe to retry.
  const result = await refreshOfflineToken(shop);
  if (result === 'reauthorize') {
    return res.status(401).json({error: 'reauthenticate'});
  }
  if (result === 'retry') {
    return res.status(502).json({error: 'Token refresh failed'});
  }
  if (result === 'failed') {
    // Not transient: retrying sends the identical request. Answering with
    // success here would tell the client it holds a fresh token when it doesn't.
    return res.status(502).json({error: 'Token refresh rejected'});
  }


  res.json({success: true});
});


// express.json() throws on a malformed body, and Express's default error handler
// answers with an HTML page containing a stack trace and absolute file paths. This
// is a JSON API, so answer in JSON. Match only parse failures: anything else should
// keep surfacing loudly rather than be swallowed here.
app.use((err, req, res, next) => {
  if (err?.type === 'entity.parse.failed') {
    return res.status(400).json({error: 'Malformed JSON body'});
  }
  // The request never reached Shopify, so nothing was consumed and the caller can
  // try again. 503 says that; the stack trace Express would otherwise return says
  // the app is broken.
  if (err instanceof ShopifyUnreachable) {
    return res.status(503).json({error: 'Could not reach Shopify, try again'});
  }
  next(err);
});


app.listen(3000, () => console.log('Server running on http://localhost:3000'));
```

## Tutorial complete!

Your embedded app now exchanges App Bridge ID tokens for access tokens and refreshes them before they expire.

### Next steps

[Query store data\
\
](https://shopify.dev/docs/apps/build/graphql)

[Use your access token to query and mutate store data with the GraphQL Admin API.](https://shopify.dev/docs/apps/build/graphql)

[Set up webhooks\
\
](https://shopify.dev/docs/apps/build/webhooks)

[Register webhooks for background tasks that run using your offline access token.](https://shopify.dev/docs/apps/build/webhooks)

[Manage access scopes\
\
](https://shopify.dev/docs/apps/build/authentication-authorization/manage-access-scopes)

[Change your declared scopes after launch, and request or revoke optional scopes dynamically.](https://shopify.dev/docs/apps/build/authentication-authorization/manage-access-scopes)

[Delegate API access\
\
](https://shopify.dev/docs/apps/build/authentication-authorization/delegate-api-access)

[Give subsystems scoped, limited access to Shopify APIs without sharing your app's full credentials.](https://shopify.dev/docs/apps/build/authentication-authorization/delegate-api-access)
