---
title: Authenticate a standalone or API-only app
description: >-
  Use the OAuth authorization code grant flow to get access tokens for
  standalone and API-only apps that live outside the Shopify admin.
source_url:
  html: >-
    https://shopify.dev/docs/apps/build/authentication-authorization/authenticate-standalone-apps?lang=node
  md: >-
    https://shopify.dev/docs/apps/build/authentication-authorization/authenticate-standalone-apps.md?lang=node
---

# Authenticate a standalone or API-only app

Standalone and API-only apps run outside the Shopify admin, unlike embedded apps. A standalone app serves its own UI, and an API-only app has no UI and runs as a server-side integration. Because they can't use [ID tokens](https://shopify.dev/docs/apps/build/authentication-authorization/id-tokens), they authenticate using the OAuth [authorization code grant](https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens#authorization-code-grant) flow: redirect the merchant to Shopify, get their approval, and exchange a code for an access token.

**Info:**

If your app only acts on stores in your own Shopify organization, use the [client credentials grant](https://shopify.dev/docs/apps/build/authentication-authorization/client-credentials-grant) instead. It skips the merchant authorization flow. 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:

* Redirect a merchant to Shopify's authorization page
* Validate the callback Shopify sends back to your app
* Exchange the authorization code for an offline access token
* Make authenticated GraphQL Admin API requests

## 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.

[Redirect URI](https://shopify.dev/docs/apps/build/cli-for-apps/app-configuration#auth)

You've added your app's redirect URI to your app's settings in the Dev Dashboard, or to the `[auth]` section of your `shopify.app.toml`.

[Access scopes](https://shopify.dev/docs/api/usage/access-scopes)

You know which access scopes your app requires.

## Project

[View on GitHub](https://github.com/Shopify/example-auth--authorization-code-grant)

## Redirect to Shopify's authorization page

Send the merchant to Shopify to approve the access your app is asking for. Shopify presents a permissions prompt, and the merchant approves or denies it.

### Build the authorization URL

Construct the authorization URL and redirect the merchant to it. Include a randomly generated `state` value (nonce) and store it so you can verify it in the next step. This prevents [CSRF attacks](https://tools.ietf.org/html/rfc6819#section-3.6).

| Parameter | Description |
| - | - |
| `client_id` | Your app's [client ID](https://shopify.dev/docs/apps/build/authentication-authorization/manage-credentials). |
| `scope` | A comma-separated list of [access scopes](https://shopify.dev/docs/api/usage/access-scopes) your app needs. Some scopes grant access to [protected customer data](https://shopify.dev/docs/apps/launch/protected-customer-data), which places additional requirements on your app. |
| `redirect_uri` | The URL Shopify redirects to after authorization. Must exactly match a redirect URI you've configured in the Dev Dashboard or your app's configuration TOML file. |
| `state` | A randomly generated nonce unique to this request. |

## /node/index.js

```javascript
import express from 'express';
import crypto from 'crypto';
import cookieParser from 'cookie-parser';
import * as dotenv from 'dotenv';


dotenv.config();


// Falls back to a random secret for local dev. Set COOKIE_SECRET in production
// so signed cookies stay valid across restarts and deployments.
const COOKIE_SECRET = process.env.COOKIE_SECRET || crypto.randomBytes(64).toString('hex');


const app = express();
app.use(cookieParser(COOKIE_SECRET));


const CLIENT_ID = process.env.SHOPIFY_CLIENT_ID;
const CLIENT_SECRET = process.env.SHOPIFY_CLIENT_SECRET;
const REDIRECT_URI = process.env.REDIRECT_URI;
const SCOPES = process.env.SCOPES || 'read_products,write_orders';


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


// Send cookies only over HTTPS in production; over plain HTTP on localhost in
// dev. httpOnly + sameSite protect them the rest of the time.
const cookieOptions = {
  signed: true,
  httpOnly: true,
  sameSite: 'lax',
  secure: process.env.NODE_ENV === 'production',
};


// 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;
}


function isValidShopDomain(shop) {
  return /^[a-zA-Z0-9][a-zA-Z0-9\-]*\.myshopify\.com$/.test(shop);
}


// 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 });
  }
}


app.get('/install', (req, res) => {
  const { shop } = req.query;


  if (!isValidShopDomain(shop)) {
    return res.status(400).send('Invalid shop domain');
  }


  const nonce = crypto.randomBytes(16).toString('hex');
  // Store the nonce in a signed cookie so you can verify it against the callback
  res.cookie('oauth_state', nonce, cookieOptions);


  const authUrl = `https://${shop}/admin/oauth/authorize?` +
    new URLSearchParams({
      client_id: CLIENT_ID,
      scope: SCOPES,
      redirect_uri: REDIRECT_URI,
      state: nonce,
    });


  res.redirect(authUrl);
});


app.get('/callback', async (req, res) => {
  const { code, hmac, shop, state } = req.query;


  if (!state || state !== req.signedCookies.oauth_state) {
    return res.status(403).send('Invalid state parameter');
  }
  res.clearCookie('oauth_state');


  const params = Object.fromEntries(
    Object.entries(req.query).filter(([key]) => key !== 'hmac')
  );
  const message = Object.entries(params).sort().map(([k, v]) => `${k}=${v}`).join('&');
  const digest = crypto.createHmac('sha256', CLIENT_SECRET).update(message).digest('hex');
  const digestBuf = Buffer.from(digest);
  const hmacBuf = Buffer.from(String(hmac));
  if (digestBuf.length !== hmacBuf.length || !crypto.timingSafeEqual(digestBuf, hmacBuf)) {
    return res.status(403).send('Invalid HMAC');
  }


  if (!isValidShopDomain(shop)) {
    return res.status(400).send('Invalid shop domain');
  }


  const tokenResponse = await shopifyFetch(`https://${shop}/admin/oauth/access_token`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      Accept: 'application/json',
    },
    body: new URLSearchParams({
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
      code,
      expiring: '1',
    }),
  });


  if (!tokenResponse.ok) {
    return res.status(403).send('Token exchange failed');
  }


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


  const granted = scope.split(',');
  // A write_* grant includes its matching read_* scope, so Shopify may return
  // only the write scope. Treat a requested read_* as satisfied by its write_*.
  const missing = SCOPES.split(',').filter(s =>
    !granted.includes(s) &&
    !(s.startsWith('read_') && granted.includes(`write_${s.slice(5)}`))
  );
  if (missing.length > 0) return res.status(403).send(`Missing scopes: ${missing.join(', ')}`);


  // Store tokens server-side, keyed by shop (use a database in production).
  // Track when the access token expires so requests can refresh it in time.
  tokenStore[shop] = {
    access_token,
    refresh_token,
    expires_at: expiresAtFrom(expires_in),
  };


  // Set a signed session cookie so subsequent requests can identify the shop
  res.cookie('shop', shop, cookieOptions);
  res.json({ message: 'App installed', shop, scope });
});


// Exchange the stored refresh token for a new access token. The return value
// tells the caller how to react, and matches the refresh error handling used
// across grant types:
//   'refreshed'   — got a new access token
//   'reauthorize' — a 401 means the refresh token is terminal (expired, revoked,
//                   replayed after the one-hour retry window, or the app was
//                   uninstalled); send the merchant back through OAuth
//   'retry'       — a transient failure (network, timeout, 5xx, 429); safe to
//                   retry later with the same refresh token
//   'failed'      — any other non-OK status, such as a malformed request or bad
//                   client credentials; retrying sends the identical request and
//                   fails the same way, so surface it instead of hiding it
async function refreshAccessToken(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',
        Accept: 'application/json',
      },
      body: new URLSearchParams({
        client_id: CLIENT_ID,
        client_secret: CLIENT_SECRET,
        grant_type: 'refresh_token',
        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';
  }


  // A 401 is terminal: drop the dead token so the merchant reinstalls.
  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.get('/products', async (req, res) => {
  const shop = req.signedCookies.shop;
  if (!shop) return res.status(401).send('Not authenticated');


  let stored = tokenStore[shop];
  if (!stored) return res.status(401).send('Not authenticated');


  // Expiring access tokens are short-lived. Refresh ~60 seconds before the token
  // actually expires so a request never goes out with a token that lapses
  // mid-flight.
  if (stored.expires_at && Date.now() >= stored.expires_at - 60 * 1000) {
    const result = await refreshAccessToken(shop);
    if (result === 'reauthorize') {
      return res.status(401).send('Reauthorization required');
    }
    if (result === 'retry') {
      return res.status(503).send('Token refresh failed, try again');
    }
    if (result === 'failed') {
      // Not the merchant's problem and not worth retrying: fix the app's request
      // or credentials. Don't fall through — `stored` still holds the token that
      // is about to expire.
      return res.status(502).send('Token refresh failed');
    }
    stored = tokenStore[shop];
  }


  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: '{ products(first: 5) { edges { node { id handle } } } }' }),
    });


  let response = await callAdminApi(stored.access_token);


  // Shopify rejected the access token: it was revoked, the app's access scopes
  // changed, or it lapsed sooner than expires_in implied. This app runs outside
  // the Shopify admin, so it has no ID token to exchange — the refresh token is
  // the only way back. Try it once, then give up rather than sending the same
  // rejected token again on every later request.
  if (response.status === 401) {
    const result = await refreshAccessToken(shop);
    if (result === 'retry') {
      // Transient: the refresh token is untouched, so a later attempt is fine.
      return res.status(503).send('Token refresh failed, try again');
    }
    if (result === 'failed') {
      return res.status(502).send('Token refresh failed');
    }
    if (result !== 'refreshed') {
      // Drop the rejected token so the next request doesn't send it again.
      delete tokenStore[shop];
      return res.status(401).send('Reauthorization required');
    }


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


    // Retry once, not in a loop. A freshly refreshed token that's also rejected
    // means something is wrong beyond a lapsed credential, so stop and send the
    // merchant back through OAuth.
    if (response.status === 401) {
      delete tokenStore[shop];
      return res.status(401).send('Reauthorization required');
    }
  }


  // 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());
});


// The routes above let a transport failure or timeout propagate. The request never
// reached Shopify, so nothing was consumed and the caller can try again: 503 says
// that, while the stack trace Express would otherwise return says the app is broken.
app.use((err, req, res, next) => {
  if (err instanceof ShopifyUnreachable) {
    return res.status(503).send('Could not reach Shopify, try again');
  }
  next(err);
});


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

## Validate the callback

After the merchant approves your app, Shopify redirects to your `redirect_uri` with an authorization code and several query parameters. Validate all of them before exchanging the code.

```text
https://your-app.example.com/callback?code={authorization_code}&hmac={hmac}&shop={shop}&state={nonce}&timestamp={timestamp}
```

### Verify the `state` parameter

Compare the `state` parameter to the nonce you stored in step 1. If they don't match, reject the request.

## /node/index.js

```javascript
import express from 'express';
import crypto from 'crypto';
import cookieParser from 'cookie-parser';
import * as dotenv from 'dotenv';


dotenv.config();


// Falls back to a random secret for local dev. Set COOKIE_SECRET in production
// so signed cookies stay valid across restarts and deployments.
const COOKIE_SECRET = process.env.COOKIE_SECRET || crypto.randomBytes(64).toString('hex');


const app = express();
app.use(cookieParser(COOKIE_SECRET));


const CLIENT_ID = process.env.SHOPIFY_CLIENT_ID;
const CLIENT_SECRET = process.env.SHOPIFY_CLIENT_SECRET;
const REDIRECT_URI = process.env.REDIRECT_URI;
const SCOPES = process.env.SCOPES || 'read_products,write_orders';


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


// Send cookies only over HTTPS in production; over plain HTTP on localhost in
// dev. httpOnly + sameSite protect them the rest of the time.
const cookieOptions = {
  signed: true,
  httpOnly: true,
  sameSite: 'lax',
  secure: process.env.NODE_ENV === 'production',
};


// 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;
}


function isValidShopDomain(shop) {
  return /^[a-zA-Z0-9][a-zA-Z0-9\-]*\.myshopify\.com$/.test(shop);
}


// 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 });
  }
}


app.get('/install', (req, res) => {
  const { shop } = req.query;


  if (!isValidShopDomain(shop)) {
    return res.status(400).send('Invalid shop domain');
  }


  const nonce = crypto.randomBytes(16).toString('hex');
  // Store the nonce in a signed cookie so you can verify it against the callback
  res.cookie('oauth_state', nonce, cookieOptions);


  const authUrl = `https://${shop}/admin/oauth/authorize?` +
    new URLSearchParams({
      client_id: CLIENT_ID,
      scope: SCOPES,
      redirect_uri: REDIRECT_URI,
      state: nonce,
    });


  res.redirect(authUrl);
});


app.get('/callback', async (req, res) => {
  const { code, hmac, shop, state } = req.query;


  if (!state || state !== req.signedCookies.oauth_state) {
    return res.status(403).send('Invalid state parameter');
  }
  res.clearCookie('oauth_state');


  const params = Object.fromEntries(
    Object.entries(req.query).filter(([key]) => key !== 'hmac')
  );
  const message = Object.entries(params).sort().map(([k, v]) => `${k}=${v}`).join('&');
  const digest = crypto.createHmac('sha256', CLIENT_SECRET).update(message).digest('hex');
  const digestBuf = Buffer.from(digest);
  const hmacBuf = Buffer.from(String(hmac));
  if (digestBuf.length !== hmacBuf.length || !crypto.timingSafeEqual(digestBuf, hmacBuf)) {
    return res.status(403).send('Invalid HMAC');
  }


  if (!isValidShopDomain(shop)) {
    return res.status(400).send('Invalid shop domain');
  }


  const tokenResponse = await shopifyFetch(`https://${shop}/admin/oauth/access_token`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      Accept: 'application/json',
    },
    body: new URLSearchParams({
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
      code,
      expiring: '1',
    }),
  });


  if (!tokenResponse.ok) {
    return res.status(403).send('Token exchange failed');
  }


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


  const granted = scope.split(',');
  // A write_* grant includes its matching read_* scope, so Shopify may return
  // only the write scope. Treat a requested read_* as satisfied by its write_*.
  const missing = SCOPES.split(',').filter(s =>
    !granted.includes(s) &&
    !(s.startsWith('read_') && granted.includes(`write_${s.slice(5)}`))
  );
  if (missing.length > 0) return res.status(403).send(`Missing scopes: ${missing.join(', ')}`);


  // Store tokens server-side, keyed by shop (use a database in production).
  // Track when the access token expires so requests can refresh it in time.
  tokenStore[shop] = {
    access_token,
    refresh_token,
    expires_at: expiresAtFrom(expires_in),
  };


  // Set a signed session cookie so subsequent requests can identify the shop
  res.cookie('shop', shop, cookieOptions);
  res.json({ message: 'App installed', shop, scope });
});


// Exchange the stored refresh token for a new access token. The return value
// tells the caller how to react, and matches the refresh error handling used
// across grant types:
//   'refreshed'   — got a new access token
//   'reauthorize' — a 401 means the refresh token is terminal (expired, revoked,
//                   replayed after the one-hour retry window, or the app was
//                   uninstalled); send the merchant back through OAuth
//   'retry'       — a transient failure (network, timeout, 5xx, 429); safe to
//                   retry later with the same refresh token
//   'failed'      — any other non-OK status, such as a malformed request or bad
//                   client credentials; retrying sends the identical request and
//                   fails the same way, so surface it instead of hiding it
async function refreshAccessToken(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',
        Accept: 'application/json',
      },
      body: new URLSearchParams({
        client_id: CLIENT_ID,
        client_secret: CLIENT_SECRET,
        grant_type: 'refresh_token',
        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';
  }


  // A 401 is terminal: drop the dead token so the merchant reinstalls.
  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.get('/products', async (req, res) => {
  const shop = req.signedCookies.shop;
  if (!shop) return res.status(401).send('Not authenticated');


  let stored = tokenStore[shop];
  if (!stored) return res.status(401).send('Not authenticated');


  // Expiring access tokens are short-lived. Refresh ~60 seconds before the token
  // actually expires so a request never goes out with a token that lapses
  // mid-flight.
  if (stored.expires_at && Date.now() >= stored.expires_at - 60 * 1000) {
    const result = await refreshAccessToken(shop);
    if (result === 'reauthorize') {
      return res.status(401).send('Reauthorization required');
    }
    if (result === 'retry') {
      return res.status(503).send('Token refresh failed, try again');
    }
    if (result === 'failed') {
      // Not the merchant's problem and not worth retrying: fix the app's request
      // or credentials. Don't fall through — `stored` still holds the token that
      // is about to expire.
      return res.status(502).send('Token refresh failed');
    }
    stored = tokenStore[shop];
  }


  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: '{ products(first: 5) { edges { node { id handle } } } }' }),
    });


  let response = await callAdminApi(stored.access_token);


  // Shopify rejected the access token: it was revoked, the app's access scopes
  // changed, or it lapsed sooner than expires_in implied. This app runs outside
  // the Shopify admin, so it has no ID token to exchange — the refresh token is
  // the only way back. Try it once, then give up rather than sending the same
  // rejected token again on every later request.
  if (response.status === 401) {
    const result = await refreshAccessToken(shop);
    if (result === 'retry') {
      // Transient: the refresh token is untouched, so a later attempt is fine.
      return res.status(503).send('Token refresh failed, try again');
    }
    if (result === 'failed') {
      return res.status(502).send('Token refresh failed');
    }
    if (result !== 'refreshed') {
      // Drop the rejected token so the next request doesn't send it again.
      delete tokenStore[shop];
      return res.status(401).send('Reauthorization required');
    }


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


    // Retry once, not in a loop. A freshly refreshed token that's also rejected
    // means something is wrong beyond a lapsed credential, so stop and send the
    // merchant back through OAuth.
    if (response.status === 401) {
      delete tokenStore[shop];
      return res.status(401).send('Reauthorization required');
    }
  }


  // 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());
});


// The routes above let a transport failure or timeout propagate. The request never
// reached Shopify, so nothing was consumed and the caller can try again: 503 says
// that, while the stack trace Express would otherwise return says the app is broken.
app.use((err, req, res, next) => {
  if (err instanceof ShopifyUnreachable) {
    return res.status(503).send('Could not reach Shopify, try again');
  }
  next(err);
});


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

### Verify the HMAC

Remove the `hmac` parameter from the query string, sort the remaining parameters alphabetically, and compute an HMAC-SHA256 hash using your client secret. The result must match the `hmac` value. Use a constant-time comparison, such as `crypto.timingSafeEqual` in Node.js or `hmac.compare_digest` in Python, so the check doesn't leak timing information.

If you're using one of Shopify's API libraries, these checks are built in:

* [Node.js (`@shopify/shopify-api`)](https://github.com/Shopify/shopify-app-js/blob/main/packages/apps/shopify-api/docs/reference/auth/callback.md)
* [Ruby (`shopify-api-ruby`)](https://github.com/Shopify/shopify-api-ruby/blob/main/docs/usage/oauth.md#add-your-oauth-callback-route)

## /node/index.js

```javascript
import express from 'express';
import crypto from 'crypto';
import cookieParser from 'cookie-parser';
import * as dotenv from 'dotenv';


dotenv.config();


// Falls back to a random secret for local dev. Set COOKIE_SECRET in production
// so signed cookies stay valid across restarts and deployments.
const COOKIE_SECRET = process.env.COOKIE_SECRET || crypto.randomBytes(64).toString('hex');


const app = express();
app.use(cookieParser(COOKIE_SECRET));


const CLIENT_ID = process.env.SHOPIFY_CLIENT_ID;
const CLIENT_SECRET = process.env.SHOPIFY_CLIENT_SECRET;
const REDIRECT_URI = process.env.REDIRECT_URI;
const SCOPES = process.env.SCOPES || 'read_products,write_orders';


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


// Send cookies only over HTTPS in production; over plain HTTP on localhost in
// dev. httpOnly + sameSite protect them the rest of the time.
const cookieOptions = {
  signed: true,
  httpOnly: true,
  sameSite: 'lax',
  secure: process.env.NODE_ENV === 'production',
};


// 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;
}


function isValidShopDomain(shop) {
  return /^[a-zA-Z0-9][a-zA-Z0-9\-]*\.myshopify\.com$/.test(shop);
}


// 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 });
  }
}


app.get('/install', (req, res) => {
  const { shop } = req.query;


  if (!isValidShopDomain(shop)) {
    return res.status(400).send('Invalid shop domain');
  }


  const nonce = crypto.randomBytes(16).toString('hex');
  // Store the nonce in a signed cookie so you can verify it against the callback
  res.cookie('oauth_state', nonce, cookieOptions);


  const authUrl = `https://${shop}/admin/oauth/authorize?` +
    new URLSearchParams({
      client_id: CLIENT_ID,
      scope: SCOPES,
      redirect_uri: REDIRECT_URI,
      state: nonce,
    });


  res.redirect(authUrl);
});


app.get('/callback', async (req, res) => {
  const { code, hmac, shop, state } = req.query;


  if (!state || state !== req.signedCookies.oauth_state) {
    return res.status(403).send('Invalid state parameter');
  }
  res.clearCookie('oauth_state');


  const params = Object.fromEntries(
    Object.entries(req.query).filter(([key]) => key !== 'hmac')
  );
  const message = Object.entries(params).sort().map(([k, v]) => `${k}=${v}`).join('&');
  const digest = crypto.createHmac('sha256', CLIENT_SECRET).update(message).digest('hex');
  const digestBuf = Buffer.from(digest);
  const hmacBuf = Buffer.from(String(hmac));
  if (digestBuf.length !== hmacBuf.length || !crypto.timingSafeEqual(digestBuf, hmacBuf)) {
    return res.status(403).send('Invalid HMAC');
  }


  if (!isValidShopDomain(shop)) {
    return res.status(400).send('Invalid shop domain');
  }


  const tokenResponse = await shopifyFetch(`https://${shop}/admin/oauth/access_token`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      Accept: 'application/json',
    },
    body: new URLSearchParams({
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
      code,
      expiring: '1',
    }),
  });


  if (!tokenResponse.ok) {
    return res.status(403).send('Token exchange failed');
  }


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


  const granted = scope.split(',');
  // A write_* grant includes its matching read_* scope, so Shopify may return
  // only the write scope. Treat a requested read_* as satisfied by its write_*.
  const missing = SCOPES.split(',').filter(s =>
    !granted.includes(s) &&
    !(s.startsWith('read_') && granted.includes(`write_${s.slice(5)}`))
  );
  if (missing.length > 0) return res.status(403).send(`Missing scopes: ${missing.join(', ')}`);


  // Store tokens server-side, keyed by shop (use a database in production).
  // Track when the access token expires so requests can refresh it in time.
  tokenStore[shop] = {
    access_token,
    refresh_token,
    expires_at: expiresAtFrom(expires_in),
  };


  // Set a signed session cookie so subsequent requests can identify the shop
  res.cookie('shop', shop, cookieOptions);
  res.json({ message: 'App installed', shop, scope });
});


// Exchange the stored refresh token for a new access token. The return value
// tells the caller how to react, and matches the refresh error handling used
// across grant types:
//   'refreshed'   — got a new access token
//   'reauthorize' — a 401 means the refresh token is terminal (expired, revoked,
//                   replayed after the one-hour retry window, or the app was
//                   uninstalled); send the merchant back through OAuth
//   'retry'       — a transient failure (network, timeout, 5xx, 429); safe to
//                   retry later with the same refresh token
//   'failed'      — any other non-OK status, such as a malformed request or bad
//                   client credentials; retrying sends the identical request and
//                   fails the same way, so surface it instead of hiding it
async function refreshAccessToken(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',
        Accept: 'application/json',
      },
      body: new URLSearchParams({
        client_id: CLIENT_ID,
        client_secret: CLIENT_SECRET,
        grant_type: 'refresh_token',
        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';
  }


  // A 401 is terminal: drop the dead token so the merchant reinstalls.
  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.get('/products', async (req, res) => {
  const shop = req.signedCookies.shop;
  if (!shop) return res.status(401).send('Not authenticated');


  let stored = tokenStore[shop];
  if (!stored) return res.status(401).send('Not authenticated');


  // Expiring access tokens are short-lived. Refresh ~60 seconds before the token
  // actually expires so a request never goes out with a token that lapses
  // mid-flight.
  if (stored.expires_at && Date.now() >= stored.expires_at - 60 * 1000) {
    const result = await refreshAccessToken(shop);
    if (result === 'reauthorize') {
      return res.status(401).send('Reauthorization required');
    }
    if (result === 'retry') {
      return res.status(503).send('Token refresh failed, try again');
    }
    if (result === 'failed') {
      // Not the merchant's problem and not worth retrying: fix the app's request
      // or credentials. Don't fall through — `stored` still holds the token that
      // is about to expire.
      return res.status(502).send('Token refresh failed');
    }
    stored = tokenStore[shop];
  }


  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: '{ products(first: 5) { edges { node { id handle } } } }' }),
    });


  let response = await callAdminApi(stored.access_token);


  // Shopify rejected the access token: it was revoked, the app's access scopes
  // changed, or it lapsed sooner than expires_in implied. This app runs outside
  // the Shopify admin, so it has no ID token to exchange — the refresh token is
  // the only way back. Try it once, then give up rather than sending the same
  // rejected token again on every later request.
  if (response.status === 401) {
    const result = await refreshAccessToken(shop);
    if (result === 'retry') {
      // Transient: the refresh token is untouched, so a later attempt is fine.
      return res.status(503).send('Token refresh failed, try again');
    }
    if (result === 'failed') {
      return res.status(502).send('Token refresh failed');
    }
    if (result !== 'refreshed') {
      // Drop the rejected token so the next request doesn't send it again.
      delete tokenStore[shop];
      return res.status(401).send('Reauthorization required');
    }


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


    // Retry once, not in a loop. A freshly refreshed token that's also rejected
    // means something is wrong beyond a lapsed credential, so stop and send the
    // merchant back through OAuth.
    if (response.status === 401) {
      delete tokenStore[shop];
      return res.status(401).send('Reauthorization required');
    }
  }


  // 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());
});


// The routes above let a transport failure or timeout propagate. The request never
// reached Shopify, so nothing was consumed and the caller can try again: 503 says
// that, while the stack trace Express would otherwise return says the app is broken.
app.use((err, req, res, next) => {
  if (err instanceof ShopifyUnreachable) {
    return res.status(503).send('Could not reach Shopify, try again');
  }
  next(err);
});


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

### Validate the `shop` domain

Confirm the `shop` parameter matches `^[a-zA-Z0-9][a-zA-Z0-9\-]*\.myshopify\.com$`. Anchor the pattern at both ends. Without the trailing `$`, a value such as `exampleshop.myshopify.com.attacker.example` passes the check, and your app sends the authorization code to a host you don't control.

## /node/index.js

```javascript
import express from 'express';
import crypto from 'crypto';
import cookieParser from 'cookie-parser';
import * as dotenv from 'dotenv';


dotenv.config();


// Falls back to a random secret for local dev. Set COOKIE_SECRET in production
// so signed cookies stay valid across restarts and deployments.
const COOKIE_SECRET = process.env.COOKIE_SECRET || crypto.randomBytes(64).toString('hex');


const app = express();
app.use(cookieParser(COOKIE_SECRET));


const CLIENT_ID = process.env.SHOPIFY_CLIENT_ID;
const CLIENT_SECRET = process.env.SHOPIFY_CLIENT_SECRET;
const REDIRECT_URI = process.env.REDIRECT_URI;
const SCOPES = process.env.SCOPES || 'read_products,write_orders';


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


// Send cookies only over HTTPS in production; over plain HTTP on localhost in
// dev. httpOnly + sameSite protect them the rest of the time.
const cookieOptions = {
  signed: true,
  httpOnly: true,
  sameSite: 'lax',
  secure: process.env.NODE_ENV === 'production',
};


// 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;
}


function isValidShopDomain(shop) {
  return /^[a-zA-Z0-9][a-zA-Z0-9\-]*\.myshopify\.com$/.test(shop);
}


// 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 });
  }
}


app.get('/install', (req, res) => {
  const { shop } = req.query;


  if (!isValidShopDomain(shop)) {
    return res.status(400).send('Invalid shop domain');
  }


  const nonce = crypto.randomBytes(16).toString('hex');
  // Store the nonce in a signed cookie so you can verify it against the callback
  res.cookie('oauth_state', nonce, cookieOptions);


  const authUrl = `https://${shop}/admin/oauth/authorize?` +
    new URLSearchParams({
      client_id: CLIENT_ID,
      scope: SCOPES,
      redirect_uri: REDIRECT_URI,
      state: nonce,
    });


  res.redirect(authUrl);
});


app.get('/callback', async (req, res) => {
  const { code, hmac, shop, state } = req.query;


  if (!state || state !== req.signedCookies.oauth_state) {
    return res.status(403).send('Invalid state parameter');
  }
  res.clearCookie('oauth_state');


  const params = Object.fromEntries(
    Object.entries(req.query).filter(([key]) => key !== 'hmac')
  );
  const message = Object.entries(params).sort().map(([k, v]) => `${k}=${v}`).join('&');
  const digest = crypto.createHmac('sha256', CLIENT_SECRET).update(message).digest('hex');
  const digestBuf = Buffer.from(digest);
  const hmacBuf = Buffer.from(String(hmac));
  if (digestBuf.length !== hmacBuf.length || !crypto.timingSafeEqual(digestBuf, hmacBuf)) {
    return res.status(403).send('Invalid HMAC');
  }


  if (!isValidShopDomain(shop)) {
    return res.status(400).send('Invalid shop domain');
  }


  const tokenResponse = await shopifyFetch(`https://${shop}/admin/oauth/access_token`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      Accept: 'application/json',
    },
    body: new URLSearchParams({
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
      code,
      expiring: '1',
    }),
  });


  if (!tokenResponse.ok) {
    return res.status(403).send('Token exchange failed');
  }


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


  const granted = scope.split(',');
  // A write_* grant includes its matching read_* scope, so Shopify may return
  // only the write scope. Treat a requested read_* as satisfied by its write_*.
  const missing = SCOPES.split(',').filter(s =>
    !granted.includes(s) &&
    !(s.startsWith('read_') && granted.includes(`write_${s.slice(5)}`))
  );
  if (missing.length > 0) return res.status(403).send(`Missing scopes: ${missing.join(', ')}`);


  // Store tokens server-side, keyed by shop (use a database in production).
  // Track when the access token expires so requests can refresh it in time.
  tokenStore[shop] = {
    access_token,
    refresh_token,
    expires_at: expiresAtFrom(expires_in),
  };


  // Set a signed session cookie so subsequent requests can identify the shop
  res.cookie('shop', shop, cookieOptions);
  res.json({ message: 'App installed', shop, scope });
});


// Exchange the stored refresh token for a new access token. The return value
// tells the caller how to react, and matches the refresh error handling used
// across grant types:
//   'refreshed'   — got a new access token
//   'reauthorize' — a 401 means the refresh token is terminal (expired, revoked,
//                   replayed after the one-hour retry window, or the app was
//                   uninstalled); send the merchant back through OAuth
//   'retry'       — a transient failure (network, timeout, 5xx, 429); safe to
//                   retry later with the same refresh token
//   'failed'      — any other non-OK status, such as a malformed request or bad
//                   client credentials; retrying sends the identical request and
//                   fails the same way, so surface it instead of hiding it
async function refreshAccessToken(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',
        Accept: 'application/json',
      },
      body: new URLSearchParams({
        client_id: CLIENT_ID,
        client_secret: CLIENT_SECRET,
        grant_type: 'refresh_token',
        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';
  }


  // A 401 is terminal: drop the dead token so the merchant reinstalls.
  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.get('/products', async (req, res) => {
  const shop = req.signedCookies.shop;
  if (!shop) return res.status(401).send('Not authenticated');


  let stored = tokenStore[shop];
  if (!stored) return res.status(401).send('Not authenticated');


  // Expiring access tokens are short-lived. Refresh ~60 seconds before the token
  // actually expires so a request never goes out with a token that lapses
  // mid-flight.
  if (stored.expires_at && Date.now() >= stored.expires_at - 60 * 1000) {
    const result = await refreshAccessToken(shop);
    if (result === 'reauthorize') {
      return res.status(401).send('Reauthorization required');
    }
    if (result === 'retry') {
      return res.status(503).send('Token refresh failed, try again');
    }
    if (result === 'failed') {
      // Not the merchant's problem and not worth retrying: fix the app's request
      // or credentials. Don't fall through — `stored` still holds the token that
      // is about to expire.
      return res.status(502).send('Token refresh failed');
    }
    stored = tokenStore[shop];
  }


  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: '{ products(first: 5) { edges { node { id handle } } } }' }),
    });


  let response = await callAdminApi(stored.access_token);


  // Shopify rejected the access token: it was revoked, the app's access scopes
  // changed, or it lapsed sooner than expires_in implied. This app runs outside
  // the Shopify admin, so it has no ID token to exchange — the refresh token is
  // the only way back. Try it once, then give up rather than sending the same
  // rejected token again on every later request.
  if (response.status === 401) {
    const result = await refreshAccessToken(shop);
    if (result === 'retry') {
      // Transient: the refresh token is untouched, so a later attempt is fine.
      return res.status(503).send('Token refresh failed, try again');
    }
    if (result === 'failed') {
      return res.status(502).send('Token refresh failed');
    }
    if (result !== 'refreshed') {
      // Drop the rejected token so the next request doesn't send it again.
      delete tokenStore[shop];
      return res.status(401).send('Reauthorization required');
    }


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


    // Retry once, not in a loop. A freshly refreshed token that's also rejected
    // means something is wrong beyond a lapsed credential, so stop and send the
    // merchant back through OAuth.
    if (response.status === 401) {
      delete tokenStore[shop];
      return res.status(401).send('Reauthorization required');
    }
  }


  // 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());
});


// The routes above let a transport failure or timeout propagate. The request never
// reached Shopify, so nothing was consumed and the caller can try again: 503 says
// that, while the stack trace Express would otherwise return says the app is broken.
app.use((err, req, res, next) => {
  if (err instanceof ShopifyUnreachable) {
    return res.status(503).send('Could not reach Shopify, try again');
  }
  next(err);
});


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

## Exchange the code for an offline access token

Offline access tokens persist across sessions and aren't tied to a specific staff member. Exchange the authorization code for one by sending a `POST` request to Shopify's token endpoint.

### Send the token request

New public apps must use expiring offline access tokens. Include `expiring=1` in your request. 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).

A successful exchange returns the token and its metadata:

```json
{
  "access_token": "f85632530bf277ec9ac6f649fc327f17",
  "scope": "read_products,write_orders",
  "expires_in": 3600,
  "refresh_token": "shprt_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  "refresh_token_expires_in": 7776000
}
```

Response fields

| Field | Description |
| - | - |
| `access_token` | The token your app uses to make authenticated API requests. Store it securely. |
| `scope` | The access scopes that were granted. |
| `expires_in` | Seconds until the access token expires. |
| `refresh_token` | Used to get a new access token when the current one expires. |
| `refresh_token_expires_in` | Seconds until the refresh token expires (90 days). |

## /node/index.js

```javascript
import express from 'express';
import crypto from 'crypto';
import cookieParser from 'cookie-parser';
import * as dotenv from 'dotenv';


dotenv.config();


// Falls back to a random secret for local dev. Set COOKIE_SECRET in production
// so signed cookies stay valid across restarts and deployments.
const COOKIE_SECRET = process.env.COOKIE_SECRET || crypto.randomBytes(64).toString('hex');


const app = express();
app.use(cookieParser(COOKIE_SECRET));


const CLIENT_ID = process.env.SHOPIFY_CLIENT_ID;
const CLIENT_SECRET = process.env.SHOPIFY_CLIENT_SECRET;
const REDIRECT_URI = process.env.REDIRECT_URI;
const SCOPES = process.env.SCOPES || 'read_products,write_orders';


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


// Send cookies only over HTTPS in production; over plain HTTP on localhost in
// dev. httpOnly + sameSite protect them the rest of the time.
const cookieOptions = {
  signed: true,
  httpOnly: true,
  sameSite: 'lax',
  secure: process.env.NODE_ENV === 'production',
};


// 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;
}


function isValidShopDomain(shop) {
  return /^[a-zA-Z0-9][a-zA-Z0-9\-]*\.myshopify\.com$/.test(shop);
}


// 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 });
  }
}


app.get('/install', (req, res) => {
  const { shop } = req.query;


  if (!isValidShopDomain(shop)) {
    return res.status(400).send('Invalid shop domain');
  }


  const nonce = crypto.randomBytes(16).toString('hex');
  // Store the nonce in a signed cookie so you can verify it against the callback
  res.cookie('oauth_state', nonce, cookieOptions);


  const authUrl = `https://${shop}/admin/oauth/authorize?` +
    new URLSearchParams({
      client_id: CLIENT_ID,
      scope: SCOPES,
      redirect_uri: REDIRECT_URI,
      state: nonce,
    });


  res.redirect(authUrl);
});


app.get('/callback', async (req, res) => {
  const { code, hmac, shop, state } = req.query;


  if (!state || state !== req.signedCookies.oauth_state) {
    return res.status(403).send('Invalid state parameter');
  }
  res.clearCookie('oauth_state');


  const params = Object.fromEntries(
    Object.entries(req.query).filter(([key]) => key !== 'hmac')
  );
  const message = Object.entries(params).sort().map(([k, v]) => `${k}=${v}`).join('&');
  const digest = crypto.createHmac('sha256', CLIENT_SECRET).update(message).digest('hex');
  const digestBuf = Buffer.from(digest);
  const hmacBuf = Buffer.from(String(hmac));
  if (digestBuf.length !== hmacBuf.length || !crypto.timingSafeEqual(digestBuf, hmacBuf)) {
    return res.status(403).send('Invalid HMAC');
  }


  if (!isValidShopDomain(shop)) {
    return res.status(400).send('Invalid shop domain');
  }


  const tokenResponse = await shopifyFetch(`https://${shop}/admin/oauth/access_token`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      Accept: 'application/json',
    },
    body: new URLSearchParams({
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
      code,
      expiring: '1',
    }),
  });


  if (!tokenResponse.ok) {
    return res.status(403).send('Token exchange failed');
  }


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


  const granted = scope.split(',');
  // A write_* grant includes its matching read_* scope, so Shopify may return
  // only the write scope. Treat a requested read_* as satisfied by its write_*.
  const missing = SCOPES.split(',').filter(s =>
    !granted.includes(s) &&
    !(s.startsWith('read_') && granted.includes(`write_${s.slice(5)}`))
  );
  if (missing.length > 0) return res.status(403).send(`Missing scopes: ${missing.join(', ')}`);


  // Store tokens server-side, keyed by shop (use a database in production).
  // Track when the access token expires so requests can refresh it in time.
  tokenStore[shop] = {
    access_token,
    refresh_token,
    expires_at: expiresAtFrom(expires_in),
  };


  // Set a signed session cookie so subsequent requests can identify the shop
  res.cookie('shop', shop, cookieOptions);
  res.json({ message: 'App installed', shop, scope });
});


// Exchange the stored refresh token for a new access token. The return value
// tells the caller how to react, and matches the refresh error handling used
// across grant types:
//   'refreshed'   — got a new access token
//   'reauthorize' — a 401 means the refresh token is terminal (expired, revoked,
//                   replayed after the one-hour retry window, or the app was
//                   uninstalled); send the merchant back through OAuth
//   'retry'       — a transient failure (network, timeout, 5xx, 429); safe to
//                   retry later with the same refresh token
//   'failed'      — any other non-OK status, such as a malformed request or bad
//                   client credentials; retrying sends the identical request and
//                   fails the same way, so surface it instead of hiding it
async function refreshAccessToken(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',
        Accept: 'application/json',
      },
      body: new URLSearchParams({
        client_id: CLIENT_ID,
        client_secret: CLIENT_SECRET,
        grant_type: 'refresh_token',
        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';
  }


  // A 401 is terminal: drop the dead token so the merchant reinstalls.
  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.get('/products', async (req, res) => {
  const shop = req.signedCookies.shop;
  if (!shop) return res.status(401).send('Not authenticated');


  let stored = tokenStore[shop];
  if (!stored) return res.status(401).send('Not authenticated');


  // Expiring access tokens are short-lived. Refresh ~60 seconds before the token
  // actually expires so a request never goes out with a token that lapses
  // mid-flight.
  if (stored.expires_at && Date.now() >= stored.expires_at - 60 * 1000) {
    const result = await refreshAccessToken(shop);
    if (result === 'reauthorize') {
      return res.status(401).send('Reauthorization required');
    }
    if (result === 'retry') {
      return res.status(503).send('Token refresh failed, try again');
    }
    if (result === 'failed') {
      // Not the merchant's problem and not worth retrying: fix the app's request
      // or credentials. Don't fall through — `stored` still holds the token that
      // is about to expire.
      return res.status(502).send('Token refresh failed');
    }
    stored = tokenStore[shop];
  }


  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: '{ products(first: 5) { edges { node { id handle } } } }' }),
    });


  let response = await callAdminApi(stored.access_token);


  // Shopify rejected the access token: it was revoked, the app's access scopes
  // changed, or it lapsed sooner than expires_in implied. This app runs outside
  // the Shopify admin, so it has no ID token to exchange — the refresh token is
  // the only way back. Try it once, then give up rather than sending the same
  // rejected token again on every later request.
  if (response.status === 401) {
    const result = await refreshAccessToken(shop);
    if (result === 'retry') {
      // Transient: the refresh token is untouched, so a later attempt is fine.
      return res.status(503).send('Token refresh failed, try again');
    }
    if (result === 'failed') {
      return res.status(502).send('Token refresh failed');
    }
    if (result !== 'refreshed') {
      // Drop the rejected token so the next request doesn't send it again.
      delete tokenStore[shop];
      return res.status(401).send('Reauthorization required');
    }


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


    // Retry once, not in a loop. A freshly refreshed token that's also rejected
    // means something is wrong beyond a lapsed credential, so stop and send the
    // merchant back through OAuth.
    if (response.status === 401) {
      delete tokenStore[shop];
      return res.status(401).send('Reauthorization required');
    }
  }


  // 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());
});


// The routes above let a transport failure or timeout propagate. The request never
// reached Shopify, so nothing was consumed and the caller can try again: 503 says
// that, while the stack trace Express would otherwise return says the app is broken.
app.use((err, req, res, next) => {
  if (err instanceof ShopifyUnreachable) {
    return res.status(503).send('Could not reach Shopify, try again');
  }
  next(err);
});


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

### Confirm the granted scopes

Check that `scope` in the response includes all the scopes your app requires. Merchants can modify requested scopes during authorization, so always verify before storing the token. A `write_*` scope includes its matching `read_*` scope, so when you check the granted `scope` list, look for the write scope you requested rather than a separate read scope.

To request additional scopes later, redirect the merchant to the authorization URL again with the updated scope list. Shopify prompts them to approve the added permissions. Apps built with Shopify CLI update scopes through managed installation instead. See [Authentication for apps built with Shopify CLI](https://shopify.dev/docs/apps/build/authentication-authorization/cli-app-authentication#customize-your-access-scopes).

## /node/index.js

```javascript
import express from 'express';
import crypto from 'crypto';
import cookieParser from 'cookie-parser';
import * as dotenv from 'dotenv';


dotenv.config();


// Falls back to a random secret for local dev. Set COOKIE_SECRET in production
// so signed cookies stay valid across restarts and deployments.
const COOKIE_SECRET = process.env.COOKIE_SECRET || crypto.randomBytes(64).toString('hex');


const app = express();
app.use(cookieParser(COOKIE_SECRET));


const CLIENT_ID = process.env.SHOPIFY_CLIENT_ID;
const CLIENT_SECRET = process.env.SHOPIFY_CLIENT_SECRET;
const REDIRECT_URI = process.env.REDIRECT_URI;
const SCOPES = process.env.SCOPES || 'read_products,write_orders';


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


// Send cookies only over HTTPS in production; over plain HTTP on localhost in
// dev. httpOnly + sameSite protect them the rest of the time.
const cookieOptions = {
  signed: true,
  httpOnly: true,
  sameSite: 'lax',
  secure: process.env.NODE_ENV === 'production',
};


// 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;
}


function isValidShopDomain(shop) {
  return /^[a-zA-Z0-9][a-zA-Z0-9\-]*\.myshopify\.com$/.test(shop);
}


// 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 });
  }
}


app.get('/install', (req, res) => {
  const { shop } = req.query;


  if (!isValidShopDomain(shop)) {
    return res.status(400).send('Invalid shop domain');
  }


  const nonce = crypto.randomBytes(16).toString('hex');
  // Store the nonce in a signed cookie so you can verify it against the callback
  res.cookie('oauth_state', nonce, cookieOptions);


  const authUrl = `https://${shop}/admin/oauth/authorize?` +
    new URLSearchParams({
      client_id: CLIENT_ID,
      scope: SCOPES,
      redirect_uri: REDIRECT_URI,
      state: nonce,
    });


  res.redirect(authUrl);
});


app.get('/callback', async (req, res) => {
  const { code, hmac, shop, state } = req.query;


  if (!state || state !== req.signedCookies.oauth_state) {
    return res.status(403).send('Invalid state parameter');
  }
  res.clearCookie('oauth_state');


  const params = Object.fromEntries(
    Object.entries(req.query).filter(([key]) => key !== 'hmac')
  );
  const message = Object.entries(params).sort().map(([k, v]) => `${k}=${v}`).join('&');
  const digest = crypto.createHmac('sha256', CLIENT_SECRET).update(message).digest('hex');
  const digestBuf = Buffer.from(digest);
  const hmacBuf = Buffer.from(String(hmac));
  if (digestBuf.length !== hmacBuf.length || !crypto.timingSafeEqual(digestBuf, hmacBuf)) {
    return res.status(403).send('Invalid HMAC');
  }


  if (!isValidShopDomain(shop)) {
    return res.status(400).send('Invalid shop domain');
  }


  const tokenResponse = await shopifyFetch(`https://${shop}/admin/oauth/access_token`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      Accept: 'application/json',
    },
    body: new URLSearchParams({
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
      code,
      expiring: '1',
    }),
  });


  if (!tokenResponse.ok) {
    return res.status(403).send('Token exchange failed');
  }


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


  const granted = scope.split(',');
  // A write_* grant includes its matching read_* scope, so Shopify may return
  // only the write scope. Treat a requested read_* as satisfied by its write_*.
  const missing = SCOPES.split(',').filter(s =>
    !granted.includes(s) &&
    !(s.startsWith('read_') && granted.includes(`write_${s.slice(5)}`))
  );
  if (missing.length > 0) return res.status(403).send(`Missing scopes: ${missing.join(', ')}`);


  // Store tokens server-side, keyed by shop (use a database in production).
  // Track when the access token expires so requests can refresh it in time.
  tokenStore[shop] = {
    access_token,
    refresh_token,
    expires_at: expiresAtFrom(expires_in),
  };


  // Set a signed session cookie so subsequent requests can identify the shop
  res.cookie('shop', shop, cookieOptions);
  res.json({ message: 'App installed', shop, scope });
});


// Exchange the stored refresh token for a new access token. The return value
// tells the caller how to react, and matches the refresh error handling used
// across grant types:
//   'refreshed'   — got a new access token
//   'reauthorize' — a 401 means the refresh token is terminal (expired, revoked,
//                   replayed after the one-hour retry window, or the app was
//                   uninstalled); send the merchant back through OAuth
//   'retry'       — a transient failure (network, timeout, 5xx, 429); safe to
//                   retry later with the same refresh token
//   'failed'      — any other non-OK status, such as a malformed request or bad
//                   client credentials; retrying sends the identical request and
//                   fails the same way, so surface it instead of hiding it
async function refreshAccessToken(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',
        Accept: 'application/json',
      },
      body: new URLSearchParams({
        client_id: CLIENT_ID,
        client_secret: CLIENT_SECRET,
        grant_type: 'refresh_token',
        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';
  }


  // A 401 is terminal: drop the dead token so the merchant reinstalls.
  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.get('/products', async (req, res) => {
  const shop = req.signedCookies.shop;
  if (!shop) return res.status(401).send('Not authenticated');


  let stored = tokenStore[shop];
  if (!stored) return res.status(401).send('Not authenticated');


  // Expiring access tokens are short-lived. Refresh ~60 seconds before the token
  // actually expires so a request never goes out with a token that lapses
  // mid-flight.
  if (stored.expires_at && Date.now() >= stored.expires_at - 60 * 1000) {
    const result = await refreshAccessToken(shop);
    if (result === 'reauthorize') {
      return res.status(401).send('Reauthorization required');
    }
    if (result === 'retry') {
      return res.status(503).send('Token refresh failed, try again');
    }
    if (result === 'failed') {
      // Not the merchant's problem and not worth retrying: fix the app's request
      // or credentials. Don't fall through — `stored` still holds the token that
      // is about to expire.
      return res.status(502).send('Token refresh failed');
    }
    stored = tokenStore[shop];
  }


  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: '{ products(first: 5) { edges { node { id handle } } } }' }),
    });


  let response = await callAdminApi(stored.access_token);


  // Shopify rejected the access token: it was revoked, the app's access scopes
  // changed, or it lapsed sooner than expires_in implied. This app runs outside
  // the Shopify admin, so it has no ID token to exchange — the refresh token is
  // the only way back. Try it once, then give up rather than sending the same
  // rejected token again on every later request.
  if (response.status === 401) {
    const result = await refreshAccessToken(shop);
    if (result === 'retry') {
      // Transient: the refresh token is untouched, so a later attempt is fine.
      return res.status(503).send('Token refresh failed, try again');
    }
    if (result === 'failed') {
      return res.status(502).send('Token refresh failed');
    }
    if (result !== 'refreshed') {
      // Drop the rejected token so the next request doesn't send it again.
      delete tokenStore[shop];
      return res.status(401).send('Reauthorization required');
    }


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


    // Retry once, not in a loop. A freshly refreshed token that's also rejected
    // means something is wrong beyond a lapsed credential, so stop and send the
    // merchant back through OAuth.
    if (response.status === 401) {
      delete tokenStore[shop];
      return res.status(401).send('Reauthorization required');
    }
  }


  // 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());
});


// The routes above let a transport failure or timeout propagate. The request never
// reached Shopify, so nothing was consumed and the caller can try again: 503 says
// that, while the stack trace Express would otherwise return says the app is broken.
app.use((err, req, res, next) => {
  if (err instanceof ShopifyUnreachable) {
    return res.status(503).send('Could not reach Shopify, try again');
  }
  next(err);
});


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

### Store the tokens

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 token expires.

## /node/index.js

```javascript
import express from 'express';
import crypto from 'crypto';
import cookieParser from 'cookie-parser';
import * as dotenv from 'dotenv';


dotenv.config();


// Falls back to a random secret for local dev. Set COOKIE_SECRET in production
// so signed cookies stay valid across restarts and deployments.
const COOKIE_SECRET = process.env.COOKIE_SECRET || crypto.randomBytes(64).toString('hex');


const app = express();
app.use(cookieParser(COOKIE_SECRET));


const CLIENT_ID = process.env.SHOPIFY_CLIENT_ID;
const CLIENT_SECRET = process.env.SHOPIFY_CLIENT_SECRET;
const REDIRECT_URI = process.env.REDIRECT_URI;
const SCOPES = process.env.SCOPES || 'read_products,write_orders';


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


// Send cookies only over HTTPS in production; over plain HTTP on localhost in
// dev. httpOnly + sameSite protect them the rest of the time.
const cookieOptions = {
  signed: true,
  httpOnly: true,
  sameSite: 'lax',
  secure: process.env.NODE_ENV === 'production',
};


// 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;
}


function isValidShopDomain(shop) {
  return /^[a-zA-Z0-9][a-zA-Z0-9\-]*\.myshopify\.com$/.test(shop);
}


// 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 });
  }
}


app.get('/install', (req, res) => {
  const { shop } = req.query;


  if (!isValidShopDomain(shop)) {
    return res.status(400).send('Invalid shop domain');
  }


  const nonce = crypto.randomBytes(16).toString('hex');
  // Store the nonce in a signed cookie so you can verify it against the callback
  res.cookie('oauth_state', nonce, cookieOptions);


  const authUrl = `https://${shop}/admin/oauth/authorize?` +
    new URLSearchParams({
      client_id: CLIENT_ID,
      scope: SCOPES,
      redirect_uri: REDIRECT_URI,
      state: nonce,
    });


  res.redirect(authUrl);
});


app.get('/callback', async (req, res) => {
  const { code, hmac, shop, state } = req.query;


  if (!state || state !== req.signedCookies.oauth_state) {
    return res.status(403).send('Invalid state parameter');
  }
  res.clearCookie('oauth_state');


  const params = Object.fromEntries(
    Object.entries(req.query).filter(([key]) => key !== 'hmac')
  );
  const message = Object.entries(params).sort().map(([k, v]) => `${k}=${v}`).join('&');
  const digest = crypto.createHmac('sha256', CLIENT_SECRET).update(message).digest('hex');
  const digestBuf = Buffer.from(digest);
  const hmacBuf = Buffer.from(String(hmac));
  if (digestBuf.length !== hmacBuf.length || !crypto.timingSafeEqual(digestBuf, hmacBuf)) {
    return res.status(403).send('Invalid HMAC');
  }


  if (!isValidShopDomain(shop)) {
    return res.status(400).send('Invalid shop domain');
  }


  const tokenResponse = await shopifyFetch(`https://${shop}/admin/oauth/access_token`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      Accept: 'application/json',
    },
    body: new URLSearchParams({
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
      code,
      expiring: '1',
    }),
  });


  if (!tokenResponse.ok) {
    return res.status(403).send('Token exchange failed');
  }


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


  const granted = scope.split(',');
  // A write_* grant includes its matching read_* scope, so Shopify may return
  // only the write scope. Treat a requested read_* as satisfied by its write_*.
  const missing = SCOPES.split(',').filter(s =>
    !granted.includes(s) &&
    !(s.startsWith('read_') && granted.includes(`write_${s.slice(5)}`))
  );
  if (missing.length > 0) return res.status(403).send(`Missing scopes: ${missing.join(', ')}`);


  // Store tokens server-side, keyed by shop (use a database in production).
  // Track when the access token expires so requests can refresh it in time.
  tokenStore[shop] = {
    access_token,
    refresh_token,
    expires_at: expiresAtFrom(expires_in),
  };


  // Set a signed session cookie so subsequent requests can identify the shop
  res.cookie('shop', shop, cookieOptions);
  res.json({ message: 'App installed', shop, scope });
});


// Exchange the stored refresh token for a new access token. The return value
// tells the caller how to react, and matches the refresh error handling used
// across grant types:
//   'refreshed'   — got a new access token
//   'reauthorize' — a 401 means the refresh token is terminal (expired, revoked,
//                   replayed after the one-hour retry window, or the app was
//                   uninstalled); send the merchant back through OAuth
//   'retry'       — a transient failure (network, timeout, 5xx, 429); safe to
//                   retry later with the same refresh token
//   'failed'      — any other non-OK status, such as a malformed request or bad
//                   client credentials; retrying sends the identical request and
//                   fails the same way, so surface it instead of hiding it
async function refreshAccessToken(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',
        Accept: 'application/json',
      },
      body: new URLSearchParams({
        client_id: CLIENT_ID,
        client_secret: CLIENT_SECRET,
        grant_type: 'refresh_token',
        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';
  }


  // A 401 is terminal: drop the dead token so the merchant reinstalls.
  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.get('/products', async (req, res) => {
  const shop = req.signedCookies.shop;
  if (!shop) return res.status(401).send('Not authenticated');


  let stored = tokenStore[shop];
  if (!stored) return res.status(401).send('Not authenticated');


  // Expiring access tokens are short-lived. Refresh ~60 seconds before the token
  // actually expires so a request never goes out with a token that lapses
  // mid-flight.
  if (stored.expires_at && Date.now() >= stored.expires_at - 60 * 1000) {
    const result = await refreshAccessToken(shop);
    if (result === 'reauthorize') {
      return res.status(401).send('Reauthorization required');
    }
    if (result === 'retry') {
      return res.status(503).send('Token refresh failed, try again');
    }
    if (result === 'failed') {
      // Not the merchant's problem and not worth retrying: fix the app's request
      // or credentials. Don't fall through — `stored` still holds the token that
      // is about to expire.
      return res.status(502).send('Token refresh failed');
    }
    stored = tokenStore[shop];
  }


  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: '{ products(first: 5) { edges { node { id handle } } } }' }),
    });


  let response = await callAdminApi(stored.access_token);


  // Shopify rejected the access token: it was revoked, the app's access scopes
  // changed, or it lapsed sooner than expires_in implied. This app runs outside
  // the Shopify admin, so it has no ID token to exchange — the refresh token is
  // the only way back. Try it once, then give up rather than sending the same
  // rejected token again on every later request.
  if (response.status === 401) {
    const result = await refreshAccessToken(shop);
    if (result === 'retry') {
      // Transient: the refresh token is untouched, so a later attempt is fine.
      return res.status(503).send('Token refresh failed, try again');
    }
    if (result === 'failed') {
      return res.status(502).send('Token refresh failed');
    }
    if (result !== 'refreshed') {
      // Drop the rejected token so the next request doesn't send it again.
      delete tokenStore[shop];
      return res.status(401).send('Reauthorization required');
    }


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


    // Retry once, not in a loop. A freshly refreshed token that's also rejected
    // means something is wrong beyond a lapsed credential, so stop and send the
    // merchant back through OAuth.
    if (response.status === 401) {
      delete tokenStore[shop];
      return res.status(401).send('Reauthorization required');
    }
  }


  // 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());
});


// The routes above let a transport failure or timeout propagate. The request never
// reached Shopify, so nothing was consumed and the caller can try again: 503 says
// that, while the stack trace Express would otherwise return says the app is broken.
app.use((err, req, res, next) => {
  if (err instanceof ShopifyUnreachable) {
    return res.status(503).send('Could not reach Shopify, try again');
  }
  next(err);
});


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

## (Optional) Request an online access token

Online access tokens are scoped to the staff member who authorized them, and they expire with that user's session. Request one when your app needs to respect an individual user's permission level or attribute actions to a specific user. Skip this step if an offline token is all your app needs.

A single authorization request returns one token type, so this step changes the flow you just built rather than adding to it. Your app can hold both, but each one needs its own record: key the offline token by shop, and each online token by shop and staff member. An online access token has no refresh token, so writing one into the offline token's record ends your app's background refresh. The samples on this page keep a single record per shop, so add a second store before you run this flow.

### Add the per-user parameter to the authorization URL

Append `&grant_options[]=per-user` to the authorization URL you built in [Redirect to Shopify's authorization page](#redirect-to-shopifys-authorization-page), then run the flow again. The code exchange returns an online access token instead of an offline one, and the response adds `associated_user` and `associated_user_scope`.

```text
https://{shop}/admin/oauth/authorize?client_id={client_id}&scope={scopes}&redirect_uri={redirect_uri}&state={nonce}&grant_options[]=per-user
```

For what those fields contain, how long the token lasts, what a user logging out revokes, and why a valid token can still return `403`, see [Online access tokens](https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens#online-access-tokens).

## 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. When it nears expiry, use the stored `refresh_token` to get a new one rather than sending the merchant back through authorization. See [Refresh the access token](#refresh-the-access-token).

## /node/index.js

```javascript
import express from 'express';
import crypto from 'crypto';
import cookieParser from 'cookie-parser';
import * as dotenv from 'dotenv';


dotenv.config();


// Falls back to a random secret for local dev. Set COOKIE_SECRET in production
// so signed cookies stay valid across restarts and deployments.
const COOKIE_SECRET = process.env.COOKIE_SECRET || crypto.randomBytes(64).toString('hex');


const app = express();
app.use(cookieParser(COOKIE_SECRET));


const CLIENT_ID = process.env.SHOPIFY_CLIENT_ID;
const CLIENT_SECRET = process.env.SHOPIFY_CLIENT_SECRET;
const REDIRECT_URI = process.env.REDIRECT_URI;
const SCOPES = process.env.SCOPES || 'read_products,write_orders';


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


// Send cookies only over HTTPS in production; over plain HTTP on localhost in
// dev. httpOnly + sameSite protect them the rest of the time.
const cookieOptions = {
  signed: true,
  httpOnly: true,
  sameSite: 'lax',
  secure: process.env.NODE_ENV === 'production',
};


// 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;
}


function isValidShopDomain(shop) {
  return /^[a-zA-Z0-9][a-zA-Z0-9\-]*\.myshopify\.com$/.test(shop);
}


// 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 });
  }
}


app.get('/install', (req, res) => {
  const { shop } = req.query;


  if (!isValidShopDomain(shop)) {
    return res.status(400).send('Invalid shop domain');
  }


  const nonce = crypto.randomBytes(16).toString('hex');
  // Store the nonce in a signed cookie so you can verify it against the callback
  res.cookie('oauth_state', nonce, cookieOptions);


  const authUrl = `https://${shop}/admin/oauth/authorize?` +
    new URLSearchParams({
      client_id: CLIENT_ID,
      scope: SCOPES,
      redirect_uri: REDIRECT_URI,
      state: nonce,
    });


  res.redirect(authUrl);
});


app.get('/callback', async (req, res) => {
  const { code, hmac, shop, state } = req.query;


  if (!state || state !== req.signedCookies.oauth_state) {
    return res.status(403).send('Invalid state parameter');
  }
  res.clearCookie('oauth_state');


  const params = Object.fromEntries(
    Object.entries(req.query).filter(([key]) => key !== 'hmac')
  );
  const message = Object.entries(params).sort().map(([k, v]) => `${k}=${v}`).join('&');
  const digest = crypto.createHmac('sha256', CLIENT_SECRET).update(message).digest('hex');
  const digestBuf = Buffer.from(digest);
  const hmacBuf = Buffer.from(String(hmac));
  if (digestBuf.length !== hmacBuf.length || !crypto.timingSafeEqual(digestBuf, hmacBuf)) {
    return res.status(403).send('Invalid HMAC');
  }


  if (!isValidShopDomain(shop)) {
    return res.status(400).send('Invalid shop domain');
  }


  const tokenResponse = await shopifyFetch(`https://${shop}/admin/oauth/access_token`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      Accept: 'application/json',
    },
    body: new URLSearchParams({
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
      code,
      expiring: '1',
    }),
  });


  if (!tokenResponse.ok) {
    return res.status(403).send('Token exchange failed');
  }


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


  const granted = scope.split(',');
  // A write_* grant includes its matching read_* scope, so Shopify may return
  // only the write scope. Treat a requested read_* as satisfied by its write_*.
  const missing = SCOPES.split(',').filter(s =>
    !granted.includes(s) &&
    !(s.startsWith('read_') && granted.includes(`write_${s.slice(5)}`))
  );
  if (missing.length > 0) return res.status(403).send(`Missing scopes: ${missing.join(', ')}`);


  // Store tokens server-side, keyed by shop (use a database in production).
  // Track when the access token expires so requests can refresh it in time.
  tokenStore[shop] = {
    access_token,
    refresh_token,
    expires_at: expiresAtFrom(expires_in),
  };


  // Set a signed session cookie so subsequent requests can identify the shop
  res.cookie('shop', shop, cookieOptions);
  res.json({ message: 'App installed', shop, scope });
});


// Exchange the stored refresh token for a new access token. The return value
// tells the caller how to react, and matches the refresh error handling used
// across grant types:
//   'refreshed'   — got a new access token
//   'reauthorize' — a 401 means the refresh token is terminal (expired, revoked,
//                   replayed after the one-hour retry window, or the app was
//                   uninstalled); send the merchant back through OAuth
//   'retry'       — a transient failure (network, timeout, 5xx, 429); safe to
//                   retry later with the same refresh token
//   'failed'      — any other non-OK status, such as a malformed request or bad
//                   client credentials; retrying sends the identical request and
//                   fails the same way, so surface it instead of hiding it
async function refreshAccessToken(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',
        Accept: 'application/json',
      },
      body: new URLSearchParams({
        client_id: CLIENT_ID,
        client_secret: CLIENT_SECRET,
        grant_type: 'refresh_token',
        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';
  }


  // A 401 is terminal: drop the dead token so the merchant reinstalls.
  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.get('/products', async (req, res) => {
  const shop = req.signedCookies.shop;
  if (!shop) return res.status(401).send('Not authenticated');


  let stored = tokenStore[shop];
  if (!stored) return res.status(401).send('Not authenticated');


  // Expiring access tokens are short-lived. Refresh ~60 seconds before the token
  // actually expires so a request never goes out with a token that lapses
  // mid-flight.
  if (stored.expires_at && Date.now() >= stored.expires_at - 60 * 1000) {
    const result = await refreshAccessToken(shop);
    if (result === 'reauthorize') {
      return res.status(401).send('Reauthorization required');
    }
    if (result === 'retry') {
      return res.status(503).send('Token refresh failed, try again');
    }
    if (result === 'failed') {
      // Not the merchant's problem and not worth retrying: fix the app's request
      // or credentials. Don't fall through — `stored` still holds the token that
      // is about to expire.
      return res.status(502).send('Token refresh failed');
    }
    stored = tokenStore[shop];
  }


  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: '{ products(first: 5) { edges { node { id handle } } } }' }),
    });


  let response = await callAdminApi(stored.access_token);


  // Shopify rejected the access token: it was revoked, the app's access scopes
  // changed, or it lapsed sooner than expires_in implied. This app runs outside
  // the Shopify admin, so it has no ID token to exchange — the refresh token is
  // the only way back. Try it once, then give up rather than sending the same
  // rejected token again on every later request.
  if (response.status === 401) {
    const result = await refreshAccessToken(shop);
    if (result === 'retry') {
      // Transient: the refresh token is untouched, so a later attempt is fine.
      return res.status(503).send('Token refresh failed, try again');
    }
    if (result === 'failed') {
      return res.status(502).send('Token refresh failed');
    }
    if (result !== 'refreshed') {
      // Drop the rejected token so the next request doesn't send it again.
      delete tokenStore[shop];
      return res.status(401).send('Reauthorization required');
    }


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


    // Retry once, not in a loop. A freshly refreshed token that's also rejected
    // means something is wrong beyond a lapsed credential, so stop and send the
    // merchant back through OAuth.
    if (response.status === 401) {
      delete tokenStore[shop];
      return res.status(401).send('Reauthorization required');
    }
  }


  // 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());
});


// The routes above let a transport failure or timeout propagate. The request never
// reached Shopify, so nothing was consumed and the caller can try again: 503 says
// that, while the stack trace Express would otherwise return says the app is broken.
app.use((err, req, res, next) => {
  if (err instanceof ShopifyUnreachable) {
    return res.status(503).send('Could not reach Shopify, try again');
  }
  next(err);
});


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

## Refresh the access token

Expiring offline access tokens come with a `refresh_token`. Before the access token expires, exchange the refresh token for a new access token so your app keeps working without sending the merchant back through authorization.

### Exchange the refresh token

Send the stored `refresh_token` to the token endpoint before `expires_in` seconds elapse. The response contains a new access token and a new refresh token, each with updated expiry times, so store both and discard the old pair.

## Request

```terminal
curl -X POST \
  https://{shop}.myshopify.com/admin/oauth/access_token \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -H 'Accept: application/json' \
  -d 'client_id={client_id}' \
  -d 'client_secret={client_secret}' \
  -d 'grant_type=refresh_token' \
  -d 'refresh_token={refresh_token}'
```

## Response

```json
{
  "access_token": "shpat_yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy",
  "expires_in": 3600,
  "refresh_token": "shprt_zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
  "refresh_token_expires_in": 7776000,
  "scope": "write_products,read_orders"
}
```

## /node/index.js

```javascript
import express from 'express';
import crypto from 'crypto';
import cookieParser from 'cookie-parser';
import * as dotenv from 'dotenv';


dotenv.config();


// Falls back to a random secret for local dev. Set COOKIE_SECRET in production
// so signed cookies stay valid across restarts and deployments.
const COOKIE_SECRET = process.env.COOKIE_SECRET || crypto.randomBytes(64).toString('hex');


const app = express();
app.use(cookieParser(COOKIE_SECRET));


const CLIENT_ID = process.env.SHOPIFY_CLIENT_ID;
const CLIENT_SECRET = process.env.SHOPIFY_CLIENT_SECRET;
const REDIRECT_URI = process.env.REDIRECT_URI;
const SCOPES = process.env.SCOPES || 'read_products,write_orders';


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


// Send cookies only over HTTPS in production; over plain HTTP on localhost in
// dev. httpOnly + sameSite protect them the rest of the time.
const cookieOptions = {
  signed: true,
  httpOnly: true,
  sameSite: 'lax',
  secure: process.env.NODE_ENV === 'production',
};


// 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;
}


function isValidShopDomain(shop) {
  return /^[a-zA-Z0-9][a-zA-Z0-9\-]*\.myshopify\.com$/.test(shop);
}


// 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 });
  }
}


app.get('/install', (req, res) => {
  const { shop } = req.query;


  if (!isValidShopDomain(shop)) {
    return res.status(400).send('Invalid shop domain');
  }


  const nonce = crypto.randomBytes(16).toString('hex');
  // Store the nonce in a signed cookie so you can verify it against the callback
  res.cookie('oauth_state', nonce, cookieOptions);


  const authUrl = `https://${shop}/admin/oauth/authorize?` +
    new URLSearchParams({
      client_id: CLIENT_ID,
      scope: SCOPES,
      redirect_uri: REDIRECT_URI,
      state: nonce,
    });


  res.redirect(authUrl);
});


app.get('/callback', async (req, res) => {
  const { code, hmac, shop, state } = req.query;


  if (!state || state !== req.signedCookies.oauth_state) {
    return res.status(403).send('Invalid state parameter');
  }
  res.clearCookie('oauth_state');


  const params = Object.fromEntries(
    Object.entries(req.query).filter(([key]) => key !== 'hmac')
  );
  const message = Object.entries(params).sort().map(([k, v]) => `${k}=${v}`).join('&');
  const digest = crypto.createHmac('sha256', CLIENT_SECRET).update(message).digest('hex');
  const digestBuf = Buffer.from(digest);
  const hmacBuf = Buffer.from(String(hmac));
  if (digestBuf.length !== hmacBuf.length || !crypto.timingSafeEqual(digestBuf, hmacBuf)) {
    return res.status(403).send('Invalid HMAC');
  }


  if (!isValidShopDomain(shop)) {
    return res.status(400).send('Invalid shop domain');
  }


  const tokenResponse = await shopifyFetch(`https://${shop}/admin/oauth/access_token`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      Accept: 'application/json',
    },
    body: new URLSearchParams({
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
      code,
      expiring: '1',
    }),
  });


  if (!tokenResponse.ok) {
    return res.status(403).send('Token exchange failed');
  }


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


  const granted = scope.split(',');
  // A write_* grant includes its matching read_* scope, so Shopify may return
  // only the write scope. Treat a requested read_* as satisfied by its write_*.
  const missing = SCOPES.split(',').filter(s =>
    !granted.includes(s) &&
    !(s.startsWith('read_') && granted.includes(`write_${s.slice(5)}`))
  );
  if (missing.length > 0) return res.status(403).send(`Missing scopes: ${missing.join(', ')}`);


  // Store tokens server-side, keyed by shop (use a database in production).
  // Track when the access token expires so requests can refresh it in time.
  tokenStore[shop] = {
    access_token,
    refresh_token,
    expires_at: expiresAtFrom(expires_in),
  };


  // Set a signed session cookie so subsequent requests can identify the shop
  res.cookie('shop', shop, cookieOptions);
  res.json({ message: 'App installed', shop, scope });
});


// Exchange the stored refresh token for a new access token. The return value
// tells the caller how to react, and matches the refresh error handling used
// across grant types:
//   'refreshed'   — got a new access token
//   'reauthorize' — a 401 means the refresh token is terminal (expired, revoked,
//                   replayed after the one-hour retry window, or the app was
//                   uninstalled); send the merchant back through OAuth
//   'retry'       — a transient failure (network, timeout, 5xx, 429); safe to
//                   retry later with the same refresh token
//   'failed'      — any other non-OK status, such as a malformed request or bad
//                   client credentials; retrying sends the identical request and
//                   fails the same way, so surface it instead of hiding it
async function refreshAccessToken(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',
        Accept: 'application/json',
      },
      body: new URLSearchParams({
        client_id: CLIENT_ID,
        client_secret: CLIENT_SECRET,
        grant_type: 'refresh_token',
        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';
  }


  // A 401 is terminal: drop the dead token so the merchant reinstalls.
  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.get('/products', async (req, res) => {
  const shop = req.signedCookies.shop;
  if (!shop) return res.status(401).send('Not authenticated');


  let stored = tokenStore[shop];
  if (!stored) return res.status(401).send('Not authenticated');


  // Expiring access tokens are short-lived. Refresh ~60 seconds before the token
  // actually expires so a request never goes out with a token that lapses
  // mid-flight.
  if (stored.expires_at && Date.now() >= stored.expires_at - 60 * 1000) {
    const result = await refreshAccessToken(shop);
    if (result === 'reauthorize') {
      return res.status(401).send('Reauthorization required');
    }
    if (result === 'retry') {
      return res.status(503).send('Token refresh failed, try again');
    }
    if (result === 'failed') {
      // Not the merchant's problem and not worth retrying: fix the app's request
      // or credentials. Don't fall through — `stored` still holds the token that
      // is about to expire.
      return res.status(502).send('Token refresh failed');
    }
    stored = tokenStore[shop];
  }


  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: '{ products(first: 5) { edges { node { id handle } } } }' }),
    });


  let response = await callAdminApi(stored.access_token);


  // Shopify rejected the access token: it was revoked, the app's access scopes
  // changed, or it lapsed sooner than expires_in implied. This app runs outside
  // the Shopify admin, so it has no ID token to exchange — the refresh token is
  // the only way back. Try it once, then give up rather than sending the same
  // rejected token again on every later request.
  if (response.status === 401) {
    const result = await refreshAccessToken(shop);
    if (result === 'retry') {
      // Transient: the refresh token is untouched, so a later attempt is fine.
      return res.status(503).send('Token refresh failed, try again');
    }
    if (result === 'failed') {
      return res.status(502).send('Token refresh failed');
    }
    if (result !== 'refreshed') {
      // Drop the rejected token so the next request doesn't send it again.
      delete tokenStore[shop];
      return res.status(401).send('Reauthorization required');
    }


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


    // Retry once, not in a loop. A freshly refreshed token that's also rejected
    // means something is wrong beyond a lapsed credential, so stop and send the
    // merchant back through OAuth.
    if (response.status === 401) {
      delete tokenStore[shop];
      return res.status(401).send('Reauthorization required');
    }
  }


  // 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());
});


// The routes above let a transport failure or timeout propagate. The request never
// reached Shopify, so nothing was consumed and the caller can try again: 503 says
// that, while the stack trace Express would otherwise return says the app is broken.
app.use((err, req, res, next) => {
  if (err instanceof ShopifyUnreachable) {
    return res.status(503).send('Could not reach Shopify, try again');
  }
  next(err);
});


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

### Handle refresh failures

Treat a successful rotation as consuming the previous offline refresh 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`, and the repeated request returns the same rotated credentials rather than issuing another set. Don't rely on a fixed retry-window duration. If the refresh token is expired or otherwise invalid, Shopify returns `401 Unauthorized` with `{"error": "invalid_request"}`. Clear the stored token pair and send the merchant back through authorization to mint a new one. For the full error behavior, see [Refresh an expiring offline token](https://shopify.dev/docs/apps/build/authentication-authorization/implement-token-exchange#refresh-an-expiring-offline-token).

## /node/index.js

```javascript
import express from 'express';
import crypto from 'crypto';
import cookieParser from 'cookie-parser';
import * as dotenv from 'dotenv';


dotenv.config();


// Falls back to a random secret for local dev. Set COOKIE_SECRET in production
// so signed cookies stay valid across restarts and deployments.
const COOKIE_SECRET = process.env.COOKIE_SECRET || crypto.randomBytes(64).toString('hex');


const app = express();
app.use(cookieParser(COOKIE_SECRET));


const CLIENT_ID = process.env.SHOPIFY_CLIENT_ID;
const CLIENT_SECRET = process.env.SHOPIFY_CLIENT_SECRET;
const REDIRECT_URI = process.env.REDIRECT_URI;
const SCOPES = process.env.SCOPES || 'read_products,write_orders';


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


// Send cookies only over HTTPS in production; over plain HTTP on localhost in
// dev. httpOnly + sameSite protect them the rest of the time.
const cookieOptions = {
  signed: true,
  httpOnly: true,
  sameSite: 'lax',
  secure: process.env.NODE_ENV === 'production',
};


// 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;
}


function isValidShopDomain(shop) {
  return /^[a-zA-Z0-9][a-zA-Z0-9\-]*\.myshopify\.com$/.test(shop);
}


// 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 });
  }
}


app.get('/install', (req, res) => {
  const { shop } = req.query;


  if (!isValidShopDomain(shop)) {
    return res.status(400).send('Invalid shop domain');
  }


  const nonce = crypto.randomBytes(16).toString('hex');
  // Store the nonce in a signed cookie so you can verify it against the callback
  res.cookie('oauth_state', nonce, cookieOptions);


  const authUrl = `https://${shop}/admin/oauth/authorize?` +
    new URLSearchParams({
      client_id: CLIENT_ID,
      scope: SCOPES,
      redirect_uri: REDIRECT_URI,
      state: nonce,
    });


  res.redirect(authUrl);
});


app.get('/callback', async (req, res) => {
  const { code, hmac, shop, state } = req.query;


  if (!state || state !== req.signedCookies.oauth_state) {
    return res.status(403).send('Invalid state parameter');
  }
  res.clearCookie('oauth_state');


  const params = Object.fromEntries(
    Object.entries(req.query).filter(([key]) => key !== 'hmac')
  );
  const message = Object.entries(params).sort().map(([k, v]) => `${k}=${v}`).join('&');
  const digest = crypto.createHmac('sha256', CLIENT_SECRET).update(message).digest('hex');
  const digestBuf = Buffer.from(digest);
  const hmacBuf = Buffer.from(String(hmac));
  if (digestBuf.length !== hmacBuf.length || !crypto.timingSafeEqual(digestBuf, hmacBuf)) {
    return res.status(403).send('Invalid HMAC');
  }


  if (!isValidShopDomain(shop)) {
    return res.status(400).send('Invalid shop domain');
  }


  const tokenResponse = await shopifyFetch(`https://${shop}/admin/oauth/access_token`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      Accept: 'application/json',
    },
    body: new URLSearchParams({
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
      code,
      expiring: '1',
    }),
  });


  if (!tokenResponse.ok) {
    return res.status(403).send('Token exchange failed');
  }


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


  const granted = scope.split(',');
  // A write_* grant includes its matching read_* scope, so Shopify may return
  // only the write scope. Treat a requested read_* as satisfied by its write_*.
  const missing = SCOPES.split(',').filter(s =>
    !granted.includes(s) &&
    !(s.startsWith('read_') && granted.includes(`write_${s.slice(5)}`))
  );
  if (missing.length > 0) return res.status(403).send(`Missing scopes: ${missing.join(', ')}`);


  // Store tokens server-side, keyed by shop (use a database in production).
  // Track when the access token expires so requests can refresh it in time.
  tokenStore[shop] = {
    access_token,
    refresh_token,
    expires_at: expiresAtFrom(expires_in),
  };


  // Set a signed session cookie so subsequent requests can identify the shop
  res.cookie('shop', shop, cookieOptions);
  res.json({ message: 'App installed', shop, scope });
});


// Exchange the stored refresh token for a new access token. The return value
// tells the caller how to react, and matches the refresh error handling used
// across grant types:
//   'refreshed'   — got a new access token
//   'reauthorize' — a 401 means the refresh token is terminal (expired, revoked,
//                   replayed after the one-hour retry window, or the app was
//                   uninstalled); send the merchant back through OAuth
//   'retry'       — a transient failure (network, timeout, 5xx, 429); safe to
//                   retry later with the same refresh token
//   'failed'      — any other non-OK status, such as a malformed request or bad
//                   client credentials; retrying sends the identical request and
//                   fails the same way, so surface it instead of hiding it
async function refreshAccessToken(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',
        Accept: 'application/json',
      },
      body: new URLSearchParams({
        client_id: CLIENT_ID,
        client_secret: CLIENT_SECRET,
        grant_type: 'refresh_token',
        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';
  }


  // A 401 is terminal: drop the dead token so the merchant reinstalls.
  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.get('/products', async (req, res) => {
  const shop = req.signedCookies.shop;
  if (!shop) return res.status(401).send('Not authenticated');


  let stored = tokenStore[shop];
  if (!stored) return res.status(401).send('Not authenticated');


  // Expiring access tokens are short-lived. Refresh ~60 seconds before the token
  // actually expires so a request never goes out with a token that lapses
  // mid-flight.
  if (stored.expires_at && Date.now() >= stored.expires_at - 60 * 1000) {
    const result = await refreshAccessToken(shop);
    if (result === 'reauthorize') {
      return res.status(401).send('Reauthorization required');
    }
    if (result === 'retry') {
      return res.status(503).send('Token refresh failed, try again');
    }
    if (result === 'failed') {
      // Not the merchant's problem and not worth retrying: fix the app's request
      // or credentials. Don't fall through — `stored` still holds the token that
      // is about to expire.
      return res.status(502).send('Token refresh failed');
    }
    stored = tokenStore[shop];
  }


  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: '{ products(first: 5) { edges { node { id handle } } } }' }),
    });


  let response = await callAdminApi(stored.access_token);


  // Shopify rejected the access token: it was revoked, the app's access scopes
  // changed, or it lapsed sooner than expires_in implied. This app runs outside
  // the Shopify admin, so it has no ID token to exchange — the refresh token is
  // the only way back. Try it once, then give up rather than sending the same
  // rejected token again on every later request.
  if (response.status === 401) {
    const result = await refreshAccessToken(shop);
    if (result === 'retry') {
      // Transient: the refresh token is untouched, so a later attempt is fine.
      return res.status(503).send('Token refresh failed, try again');
    }
    if (result === 'failed') {
      return res.status(502).send('Token refresh failed');
    }
    if (result !== 'refreshed') {
      // Drop the rejected token so the next request doesn't send it again.
      delete tokenStore[shop];
      return res.status(401).send('Reauthorization required');
    }


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


    // Retry once, not in a loop. A freshly refreshed token that's also rejected
    // means something is wrong beyond a lapsed credential, so stop and send the
    // merchant back through OAuth.
    if (response.status === 401) {
      delete tokenStore[shop];
      return res.status(401).send('Reauthorization required');
    }
  }


  // 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());
});


// The routes above let a transport failure or timeout propagate. The request never
// reached Shopify, so nothing was consumed and the caller can try again: 503 says
// that, while the stack trace Express would otherwise return says the app is broken.
app.use((err, req, res, next) => {
  if (err instanceof ShopifyUnreachable) {
    return res.status(503).send('Could not reach Shopify, try again');
  }
  next(err);
});


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

## /node/index.js

```javascript
import express from 'express';
import crypto from 'crypto';
import cookieParser from 'cookie-parser';
import * as dotenv from 'dotenv';


dotenv.config();


// Falls back to a random secret for local dev. Set COOKIE_SECRET in production
// so signed cookies stay valid across restarts and deployments.
const COOKIE_SECRET = process.env.COOKIE_SECRET || crypto.randomBytes(64).toString('hex');


const app = express();
app.use(cookieParser(COOKIE_SECRET));


const CLIENT_ID = process.env.SHOPIFY_CLIENT_ID;
const CLIENT_SECRET = process.env.SHOPIFY_CLIENT_SECRET;
const REDIRECT_URI = process.env.REDIRECT_URI;
const SCOPES = process.env.SCOPES || 'read_products,write_orders';


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


// Send cookies only over HTTPS in production; over plain HTTP on localhost in
// dev. httpOnly + sameSite protect them the rest of the time.
const cookieOptions = {
  signed: true,
  httpOnly: true,
  sameSite: 'lax',
  secure: process.env.NODE_ENV === 'production',
};


// 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;
}


function isValidShopDomain(shop) {
  return /^[a-zA-Z0-9][a-zA-Z0-9\-]*\.myshopify\.com$/.test(shop);
}


// 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 });
  }
}


app.get('/install', (req, res) => {
  const { shop } = req.query;


  if (!isValidShopDomain(shop)) {
    return res.status(400).send('Invalid shop domain');
  }


  const nonce = crypto.randomBytes(16).toString('hex');
  // Store the nonce in a signed cookie so you can verify it against the callback
  res.cookie('oauth_state', nonce, cookieOptions);


  const authUrl = `https://${shop}/admin/oauth/authorize?` +
    new URLSearchParams({
      client_id: CLIENT_ID,
      scope: SCOPES,
      redirect_uri: REDIRECT_URI,
      state: nonce,
    });


  res.redirect(authUrl);
});


app.get('/callback', async (req, res) => {
  const { code, hmac, shop, state } = req.query;


  if (!state || state !== req.signedCookies.oauth_state) {
    return res.status(403).send('Invalid state parameter');
  }
  res.clearCookie('oauth_state');


  const params = Object.fromEntries(
    Object.entries(req.query).filter(([key]) => key !== 'hmac')
  );
  const message = Object.entries(params).sort().map(([k, v]) => `${k}=${v}`).join('&');
  const digest = crypto.createHmac('sha256', CLIENT_SECRET).update(message).digest('hex');
  const digestBuf = Buffer.from(digest);
  const hmacBuf = Buffer.from(String(hmac));
  if (digestBuf.length !== hmacBuf.length || !crypto.timingSafeEqual(digestBuf, hmacBuf)) {
    return res.status(403).send('Invalid HMAC');
  }


  if (!isValidShopDomain(shop)) {
    return res.status(400).send('Invalid shop domain');
  }


  const tokenResponse = await shopifyFetch(`https://${shop}/admin/oauth/access_token`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      Accept: 'application/json',
    },
    body: new URLSearchParams({
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
      code,
      expiring: '1',
    }),
  });


  if (!tokenResponse.ok) {
    return res.status(403).send('Token exchange failed');
  }


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


  const granted = scope.split(',');
  // A write_* grant includes its matching read_* scope, so Shopify may return
  // only the write scope. Treat a requested read_* as satisfied by its write_*.
  const missing = SCOPES.split(',').filter(s =>
    !granted.includes(s) &&
    !(s.startsWith('read_') && granted.includes(`write_${s.slice(5)}`))
  );
  if (missing.length > 0) return res.status(403).send(`Missing scopes: ${missing.join(', ')}`);


  // Store tokens server-side, keyed by shop (use a database in production).
  // Track when the access token expires so requests can refresh it in time.
  tokenStore[shop] = {
    access_token,
    refresh_token,
    expires_at: expiresAtFrom(expires_in),
  };


  // Set a signed session cookie so subsequent requests can identify the shop
  res.cookie('shop', shop, cookieOptions);
  res.json({ message: 'App installed', shop, scope });
});


// Exchange the stored refresh token for a new access token. The return value
// tells the caller how to react, and matches the refresh error handling used
// across grant types:
//   'refreshed'   — got a new access token
//   'reauthorize' — a 401 means the refresh token is terminal (expired, revoked,
//                   replayed after the one-hour retry window, or the app was
//                   uninstalled); send the merchant back through OAuth
//   'retry'       — a transient failure (network, timeout, 5xx, 429); safe to
//                   retry later with the same refresh token
//   'failed'      — any other non-OK status, such as a malformed request or bad
//                   client credentials; retrying sends the identical request and
//                   fails the same way, so surface it instead of hiding it
async function refreshAccessToken(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',
        Accept: 'application/json',
      },
      body: new URLSearchParams({
        client_id: CLIENT_ID,
        client_secret: CLIENT_SECRET,
        grant_type: 'refresh_token',
        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';
  }


  // A 401 is terminal: drop the dead token so the merchant reinstalls.
  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.get('/products', async (req, res) => {
  const shop = req.signedCookies.shop;
  if (!shop) return res.status(401).send('Not authenticated');


  let stored = tokenStore[shop];
  if (!stored) return res.status(401).send('Not authenticated');


  // Expiring access tokens are short-lived. Refresh ~60 seconds before the token
  // actually expires so a request never goes out with a token that lapses
  // mid-flight.
  if (stored.expires_at && Date.now() >= stored.expires_at - 60 * 1000) {
    const result = await refreshAccessToken(shop);
    if (result === 'reauthorize') {
      return res.status(401).send('Reauthorization required');
    }
    if (result === 'retry') {
      return res.status(503).send('Token refresh failed, try again');
    }
    if (result === 'failed') {
      // Not the merchant's problem and not worth retrying: fix the app's request
      // or credentials. Don't fall through — `stored` still holds the token that
      // is about to expire.
      return res.status(502).send('Token refresh failed');
    }
    stored = tokenStore[shop];
  }


  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: '{ products(first: 5) { edges { node { id handle } } } }' }),
    });


  let response = await callAdminApi(stored.access_token);


  // Shopify rejected the access token: it was revoked, the app's access scopes
  // changed, or it lapsed sooner than expires_in implied. This app runs outside
  // the Shopify admin, so it has no ID token to exchange — the refresh token is
  // the only way back. Try it once, then give up rather than sending the same
  // rejected token again on every later request.
  if (response.status === 401) {
    const result = await refreshAccessToken(shop);
    if (result === 'retry') {
      // Transient: the refresh token is untouched, so a later attempt is fine.
      return res.status(503).send('Token refresh failed, try again');
    }
    if (result === 'failed') {
      return res.status(502).send('Token refresh failed');
    }
    if (result !== 'refreshed') {
      // Drop the rejected token so the next request doesn't send it again.
      delete tokenStore[shop];
      return res.status(401).send('Reauthorization required');
    }


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


    // Retry once, not in a loop. A freshly refreshed token that's also rejected
    // means something is wrong beyond a lapsed credential, so stop and send the
    // merchant back through OAuth.
    if (response.status === 401) {
      delete tokenStore[shop];
      return res.status(401).send('Reauthorization required');
    }
  }


  // 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());
});


// The routes above let a transport failure or timeout propagate. The request never
// reached Shopify, so nothing was consumed and the caller can try again: 503 says
// that, while the stack trace Express would otherwise return says the app is broken.
app.use((err, req, res, next) => {
  if (err instanceof ShopifyUnreachable) {
    return res.status(503).send('Could not reach Shopify, try again');
  }
  next(err);
});


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

## Tutorial complete!

Your app now completes the authorization code grant and holds an access token it can use against Shopify APIs.

### Next steps

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

[Find your client ID and secret, secure them, and rotate your client secret.](https://shopify.dev/docs/apps/build/authentication-authorization/manage-credentials)

[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)
