Shopify Session Token Verification in Node

Shopify session token verification is one HMAC check and four claim checks. Sign header.payload with your app’s client secret using HS256 and compare the result against the signature the token arrived with. Then confirm exp is in the future, nbf is in the past, aud equals your client ID, and the hostnames in iss and dest match. Anything else gets a 401.

The docs renamed it. “An ID token, previously called a session token, is a short-lived JWT that App Bridge issues to your embedded app to prove a request comes from an authenticated Shopify user,” says the ID tokens page. Same JWT, new name: the old /session-tokens URL now answers with a 301 to /id-tokens.

What is inside the token?

This is the decoded payload from Shopify’s own example:

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

Do the subtraction on those timestamps. exp minus nbf is 60, and nbf and iat are the same second. Shopify says the same thing in words: “ID tokens are short-lived: they expire one minute after they’re issued”. There is a warning attached for anyone thinking of holding on to one: “App Bridge may return a cached token with less than the full minute remaining, so don’t assume a freshly fetched token has its entire lifetime left.”

Four claims are yours to validate: exp, nbf, aud, and the iss/dest pair. The rest are informational. sub is “the user the token was issued for”, sid is “a session ID, unique per user and app”, jti is a random UUID, iat is the issue time.

An ID token authenticates the person. An access token authenticates your app. The docs are blunt about the difference: the ID token “carries no permissions, and you can’t use it to call a Shopify API”. It goes to your backend, your backend swaps it for an access token, and the access token is what talks to the Admin API.

Your frontend usually does none of this by hand. App Bridge’s fetch interceptor puts the token in the Authorization header on requests to your app’s own domain. Call shopify.idToken() yourself when the request is not a standard fetch, a WebSocket handshake being the case the ID Token API reference names. Inside an admin UI extension the call is auth.idToken() instead.

How do you verify a Shopify session token without a JWT library?

Under sixty lines of node:crypto. No dependency, and it runs anywhere Node does.

import { createHmac, timingSafeEqual } from 'node:crypto';

export interface IdTokenPayload {
  iss: string;
  dest: string;
  aud: string;
  sub: string;
  exp: number;
  nbf: number;
  iat: number;
  jti: string;
  sid: string;
}

const LEEWAY_SECONDS = 5;

export function verifyIdToken(
  token: string,
  clientId: string,
  clientSecret: string,
  now: number = Math.floor(Date.now() / 1000),
): { payload: IdTokenPayload; shop: string } {
  const parts = token.split('.');
  if (parts.length !== 3) throw new Error('Malformed ID token');
  const [head, body, signature] = parts;

  const expected = createHmac('sha256', clientSecret)
    .update(`${head}.${body}`)
    .digest('base64url');
  const got = Buffer.from(signature);
  const want = Buffer.from(expected);
  if (got.length !== want.length || !timingSafeEqual(got, want)) {
    throw new Error('Bad ID token signature');
  }

  const header = JSON.parse(Buffer.from(head, 'base64url').toString('utf8'));
  if (header.alg !== 'HS256') throw new Error(`Unexpected alg ${header.alg}`);

  const payload = JSON.parse(
    Buffer.from(body, 'base64url').toString('utf8'),
  ) as IdTokenPayload;

  if (payload.aud !== clientId) throw new Error('Audience is not this app');
  if (typeof payload.exp !== 'number' || payload.exp + LEEWAY_SECONDS < now) {
    throw new Error('ID token expired');
  }
  if (typeof payload.nbf !== 'number' || payload.nbf - LEEWAY_SECONDS > now) {
    throw new Error('ID token not valid yet');
  }
  if (typeof payload.iss !== 'string' || typeof payload.dest !== 'string') {
    throw new Error('ID token is missing iss or dest');
  }
  if (new URL(payload.iss).hostname !== new URL(payload.dest).hostname) {
    throw new Error('Issuer and destination disagree');
  }

  return { payload, shop: new URL(payload.dest).hostname };
}

Three details in there are deliberate. The signature is checked before anything is parsed, so an unauthenticated string never reaches JSON.parse. timingSafeEqual throws when the two buffers differ in length, which is why the length comparison comes first. And the alg header is read after the signature, because it never gets to choose the algorithm: HS256 is hard-coded, and the header check only exists to reject a token claiming to be something else.

Which forged tokens does that actually reject?

I minted tokens with node:crypto and ran the verifier against seventeen of them, plus five jsonwebtoken comparisons, under Node v24.12.0 with --experimental-strip-types --test. Twenty-two tests, all passing.

A token with {"alg":"none"} and an empty signature is rejected as a bad signature, as is one carrying a genuine RS256 signature from a freshly generated 2048-bit RSA key. So is a token signed with the wrong secret, one whose payload has been swapped for a different aud while keeping the original signature, and a signature of the right length with one character changed. A correctly signed token whose payload is the string not-json fails inside JSON.parse, which only ever runs on bytes the HMAC has already accepted.

The claim checks behave as expected. A token expired by six seconds is rejected, one expired by three is accepted through the leeway, nbf six seconds ahead is rejected, three seconds ahead passes. A header of hs256 in lower case is rejected too, because the comparison is an exact string match. Our guide to verifying webhook HMACs covers the other signature check a Shopify app has to get right.

One result is worth staring at. A token whose iss is https://exampleshop.myshopify.com.evil.example/admin and whose dest is https://exampleshop.myshopify.com.evil.example passes the hostname check, because that check compares the two claims to each other rather than to a list of shops you trust. That is fine as far as it goes: the HMAC signature is what makes the token unforgeable, and the iss/dest comparison only catches a mismatched pair. It stops being fine the moment you read dest and look up a database row before the signature has been checked.

Why does Shopify’s own example token say RS256?

The ID Token API reference shows the return value as 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...'. Base64url-decode that first segment and you get {"alg":"RS256","typ":"JWT"}, and that example is the only place the page names an algorithm at all. Two other pages contradict it: the ID tokens page tells you to “check the token’s signature against your client secret using HS256 (HMAC-SHA256)”, and the Node sample in Authenticate an embedded app without a template passes algorithms: ['HS256'] to jwt.verify.

Follow the instructions, not the example string. Pinning the algorithm in your own code is the whole point, because the header is attacker-supplied text.

What breaks in production: clock skew

Shopify’s sample passes algorithms and audience to jwt.verify, and no clock tolerance. jsonwebtoken defaults to zero. I measured it: version 9.0.3 rejects a token one second past exp with TokenExpiredError: jwt expired, and one with nbf three seconds ahead with NotBeforeError: jwt not active. With a 60-second token whose nbf equals its iat, a backend running a few seconds ahead of Shopify’s clock will refuse tokens that are perfectly good.

Give it a few seconds of leeway and keep the box on NTP.

There is a recovery path for the rest. When you answer a frontend XHR or fetch with a 401, set the X-Shopify-Retry-Invalid-Session-Request header: “App Bridge intercepts the response, fetches a fresh ID token, and retries the request once.” Shopify’s sample treats a 400 from the token endpoint, which is what an expired or otherwise invalid ID token gets, as the same condition. It answers with that 401 and the header. A 502 there would say the opposite, that retrying is pointless.

While measuring jsonwebtoken I also found that 9.0.3 rejects both the alg: none token and the RS256 one without being given the algorithms option, with jwt signature is required and invalid algorithm. Pass the option anyway. It costs nothing, and it puts the decision in your own code instead of in a library default.

What happens after it verifies?

Token exchange. Post the ID token you just verified to the shop’s token endpoint:

curl -X POST "https://exampleshop.myshopify.com/admin/oauth/access_token" \
  -d "client_id=$SHOPIFY_CLIENT_ID" \
  -d "client_secret=$SHOPIFY_CLIENT_SECRET" \
  -d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
  -d "subject_token=$ID_TOKEN" \
  -d "subject_token_type=urn:ietf:params:oauth:token-type:id_token" \
  -d "requested_token_type=urn:shopify:params:oauth:token-type:offline-access-token" \
  -d "expiring=1"

Ask for urn:shopify:params:oauth:token-type:online-access-token instead when you want a token tied to the staff member who opened the app. Store that one under sub as well as shop, since a shop-only key lets one staff member’s token overwrite another’s. On the offline path, Shopify’s sample sends expiring=1 and reads a refresh_token back, and our write-up on expiring offline access tokens covers the refresh cycle and the January 2027 deadline behind it.

Should you write the verifier yourself?

If you scaffolded with the Shopify CLI, no. Call authenticate.admin() and let the template do it. Write your own when the backend is not the template: a Go or Rust service, a plain Express API behind a custom frontend, a WebSocket server that gets the token from shopify.idToken() and has no fetch interceptor to lean on. On Node, jsonwebtoken with algorithms, audience and a small clockTolerance is less code than the version above and does the same job. Hand-rolling earns its place when you want zero dependencies in the request path, or when you are outside the JavaScript ecosystem and translating the check anyway. Whichever you pick, the iss/dest comparison is yours to add. dest is Shopify’s own claim, which is why Shopify’s sample also does that comparison by hand after jwt.verify returns.

We build embedded Shopify apps, and we take over ones that were scaffolded years ago and have drifted since, where the auth layer is usually the first thing worth reading. If that sounds like your app, our Shopify development work is where to start.

Need this built properly?

Whoooop Ltd has spent 15+ years building and maintaining web applications in TypeScript, React, Node.js and serverless — the same ground this post covers.

Get in touch