localhost Works, Staging 401 — JWT Audience Mismatch

JWT auth staging

Login works on http://localhost:3000. Deploy the same branch to staging and every API call returns 401. The access token decodes fine — exp is in the future, sub looks right. The API gateway rejects it anyway. This is usually not “JWT is broken.” It is claim validation differing by environment: aud, iss, JWKS URL, or clock skew.

Decode before you rotate secrets

Paste the token into a local decoder (never a random site for prod tokens). Compare:

{
  "iss": "https://auth.example.com/",
  "aud": ["https://api.example.com", "https://example.com/api"],
  "exp": 1780000000,
  "azp": "spa-local"
}

Staging API config might expect aud: https://api.staging.example.com while Auth0/Cognito still issues tokens for the local API identifier. Local API disables audience checks in NODE_ENV=development. Staging enforces them. Same token shape, different gate.

Checklist in the order that finds bugs fastest

  1. Audience (aud) — Does the API’s expected audience match a value in the token? SPAs often have an API identifier separate from the app’s Client ID. Using the Client ID as aud on the API is a common mix-up.
  2. Issuer (iss) — Trailing slash matters for some libraries (https://tenant.auth0.com/ vs without). Copy from the discovery document, do not hand-type.
  3. JWKS / algorithm — Staging pointing at the wrong tenant’s JWKS validates nothing useful; wrong kid after key rotation shows up as signature failure (still a 401).
  4. Clock skew — Container hosts without NTP reject nbf/exp near edges. Allow 60–120s leeway in the verifier.
  5. Token type — ID token vs access token. APIs must validate access tokens. ID tokens can decode “fine” and still be the wrong artifact to send as Bearer.
  6. Cookie vs Authorization header — Local uses header; staging reverse proxy strips Authorization or blocks it on cross-origin. Confirm the request leaves the browser with the header.

Config sketch that makes env drift obvious

const auth = {
  audience: process.env.API_AUDIENCE!,      // https://api.staging...
  issuer: process.env.AUTH_ISSUER!,        // from OIDC discovery
  jwksUri: process.env.AUTH_JWKS_URI!,
};

// reject boot if missing in staging/prod
if (process.env.NODE_ENV !== "development") {
  for (const [k, v] of Object.entries(auth)) {
    if (!v) throw new Error(`missing ${k}`);
  }
}

Local .env:

API_AUDIENCE=https://api.local.dev
AUTH_ISSUER=https://dev-tenant.auth0.com/

Staging secrets must not silently reuse local audience strings.

Auth0 / Cognito / Keycloak specifics

Provider quirkWhat to verify
Auth0API Identifier as audience; audience param on /authorize and token request
Cognitoclient_id sometimes appears as aud for access tokens depending on setup — know your app client
KeycloakRealm issuer URL includes /realms/{name} — wrong realm = wrong iss

If the SPA forgets audience in the authorize request, Auth0 may issue an opaque access token or an unexpected aud. Local mock APIs accept opaque tokens; staging JWT middleware does not.

Prove it with server logs

Log reason codes on failure: aud_mismatch, iss_mismatch, sig_invalid, exp, missing_bearer. “401” alone wastes a day. Temporarily log expected vs actual aud/iss (not the full token) in staging.

Minimal reproduction

  1. Capture one failing Authorization header from staging DevTools.
  2. Decode payload; write down aud and iss.
  3. Print API config expected values in a secure admin route or startup log.
  4. Diff. Fix the authorize request or the API env — whichever side is wrong.
  5. Only then consider key rotation or “rebuild the auth library.”

Local success means your happy path works with relaxed or different claims. Staging 401 means the contract was always stricter — align aud/iss and the token you actually send.