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
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-agefrom Auth0’s response, capped at your own upper bound (24h is reasonable). - On
kidcache miss, refresh JWKS once, single-flight. - Recover from
JWKS_UNREACHABLEwithout 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
- API — Validate JWT — full
VerifyPolicyschema - API — Discover OIDC provider — confirm tenant + JWKS endpoint
- API — Lint OIDC config — pre-deploy gate
- Errors — Validation findings