v0.0.1 · system status
Open APIOpenAPI 3.1Zero token storage

Validating Auth0 JWTs in production: the 8 checks Auth0's docs don't tell you

Auth0's quickstart shows signature verification. Real production needs eight checks. Here is each one, why it exists, and the failure mode it prevents.

Auth0’s getting-started page shows signature verification with jwks-rsa. Production needs more. Here are the eight checks every Auth0 verifier should run, the failure mode each one prevents, and the policy snippet that encodes all of them in one place.

TL;DR

Eight checks, not one

Signature verification is the first check, not the only one. Skip any of the other seven and you have a verifier that accepts forged-by-design tokens for the wrong tenant, expired tokens, or tokens issued for a different audience. Encode all eight as configuration, not code.

The eight checks

1. Signature                  Cryptographic match against JWKS
2. Algorithm allowlist        alg is in your fixed list (RS256 only for Auth0)
3. Issuer                     iss == https://{tenant}.auth0.com/  (trailing slash matters)
4. Audience                   aud contains your API's identifier
5. Expiry                     exp > now − clock_skew
6. Not-before                 nbf < now + clock_skew  (when present)
7. Required claims            sub, scope, custom — present and shaped right
8. Key freshness              kid resolves through the cached JWKS, refreshed within budget

Each is documented below. The pattern is: what Auth0 issues, what fails when you skip the check, what the policy field is.

1. Signature

Auth0 issues RS256 tokens signed by a per-tenant key in the tenant’s JWKS endpoint at https://{tenant}.auth0.com/.well-known/jwks.json. Your verifier fetches the JWKS, picks the key by kid, verifies.

What goes wrong without it: anyone can write a JWT and your service accepts it. Self-explanatory.

2. Algorithm allowlist

Auth0 supports RS256, RS384, RS512, ES256, ES256K, ES384, HS256. Most tenants are RS256-only. Pin to a single algorithm. Letting the token’s header pick the algorithm enables both alg=none and HS/RS key-confusion (CVE-2016-10555).

allowed_algs: ['RS256']

3. Issuer (iss)

Auth0’s iss is https://{tenant}.auth0.com/ with a trailing slash. Comparison is exact, case-sensitive. A token from a different tenant or from a custom domain (auth.acme.com) has a different iss — refuse it.

What goes wrong without it: a token signed by a different Auth0 tenant verifies cryptographically (Auth0’s keys are tenant-scoped, but the verifier doesn’t know that without the iss check). Confused-deputy across tenants.

4. Audience (aud)

Auth0’s aud is whatever you set as the API Identifier in the Auth0 dashboard. It can be a string or an array. Your verifier compares against a list and requires at least one match.

What goes wrong without it: a token issued for your frontend application verifies on your API — privilege boundary collapses.

5. Expiry (exp)

exp is a Unix timestamp. Compare against now() - clock_skew_seconds. Auth0 default lifetime for Management API tokens is 24h; for API tokens it’s whatever you configure. Long-lived tokens are a smell — pair with refresh.

clock_skew_seconds: 60

6. Not-before (nbf)

Auth0 doesn’t always emit nbf, but if it does, refuse tokens issued before now. The same clock_skew applies on the other side.

7. Required claims

Beyond the standard claims, your service usually needs:

  • sub (always)
  • scope (for OAuth-style authorization)
  • Custom claims you add via Auth0 Actions: https://acme.com/role, https://acme.com/tenant_id, etc.
required_claims: ['sub', 'scope']
required_custom_claims: { 'https://acme.com/tenant_id': 'acme-prod' }

What goes wrong without it: a token issued for a different feature (eg. without scope: write:billing) reaches a billing endpoint because the caller assumed the scope was implicit.

8. Key freshness (JWKS rotation)

Auth0 rotates signing keys. Your verifier must:

  • Cache JWKS with a TTL bounded by Cache-Control: max-age from Auth0’s response, capped at your own upper bound (24h is reasonable).
  • On kid cache miss, refresh JWKS once, single-flight.
  • Recover from JWKS_UNREACHABLE without taking down the verifier.

The failure mode: Auth0 rotates at 02:00 UTC, your verifier’s JWKS cache TTL is 7 days, every token issued after the rotation fails for your users.

The minimal Auth0 policy in one place

The policy below covers all eight checks. Wire it once; do not duplicate the values in code.

{
  "issuer": "https://acme.auth0.com/",
  "audiences": ["https://api.acme.com/"],
  "allowed_algs": ["RS256"],
  "required_claims": ["sub", "scope"],
  "required_custom_claims": {
    "https://acme.com/tenant_id": "acme-prod"
  },
  "clock_skew_seconds": 60,
  "max_ttl_seconds": 86400,
  "token_type": "JWT"
}

The JWTShield way

Three options, in order of integration speed.

Option 1 — Inline policy. Use the JSON above directly in /v1/validate/jwt:

curl -sSf -X POST "$JWTSHIELD_URL/v1/validate/jwt" \
  -H "Content-Type: application/json" \
  -d @- <<JSON
{
  "token": "$AUTH0_ACCESS_TOKEN",
  "policy": {
    "issuer": "https://acme.auth0.com/",
    "audiences": ["https://api.acme.com/"],
    "allowed_algs": ["RS256"],
    "required_claims": ["sub", "scope"],
    "clock_skew_seconds": 60
  }
}
JSON

Option 2 — Issuer profile. Register the policy upstream (via ISSUER_PROFILES_JSON) and reference it by ID. Lets you change the audience or add a required claim without touching client code.

curl -sSf -X POST "$JWTSHIELD_URL/v1/validate/jwt" \
  -H "Content-Type: application/json" \
  -d '{"token":"<jwt>","issuer_profile_id":"acme-auth0"}'

Option 3 — Preset. GET /v1/presets/auth0 returns a starter template with the algorithm + clock-skew defaults filled in. Add your audience and required claims.

curl -sSf "$JWTSHIELD_URL/v1/presets/auth0"

The validate response always returns HTTP 200; check valid and findings[]. Each finding names which of the eight checks failed and includes the offending claim values as evidence.

See also