localhost Works, Staging 401 — JWT Audience Mismatch
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
- 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 asaudon the API is a common mix-up. - Issuer (
iss) — Trailing slash matters for some libraries (https://tenant.auth0.com/vs without). Copy from the discovery document, do not hand-type. - JWKS / algorithm — Staging pointing at the wrong tenant’s JWKS validates nothing useful; wrong
kidafter key rotation shows up as signature failure (still a 401). - Clock skew — Container hosts without NTP reject
nbf/expnear edges. Allow 60–120s leeway in the verifier. - 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.
- Cookie vs Authorization header — Local uses header; staging reverse proxy strips
Authorizationor 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 quirk | What to verify |
|---|---|
| Auth0 | API Identifier as audience; audience param on /authorize and token request |
| Cognito | client_id sometimes appears as aud for access tokens depending on setup — know your app client |
| Keycloak | Realm 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
- Capture one failing Authorization header from staging DevTools.
- Decode payload; write down
audandiss. - Print API config expected values in a secure admin route or startup log.
- Diff. Fix the authorize request or the API env — whichever side is wrong.
- 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.