API Security in Depth · Authentication & Authorization for APIs
Validating Access Tokens Correctly
JWT verification, opaque tokens and introspection.
Resource servers must validate every token: signature with keys from the issuer's JWKS (cache, handle rotation by kid), pin the algorithm (never trust alg from the token; reject none), check iss, aud (must be your API), exp/nbf with small clock skew, and required scopes/claims. Use RFC 9068 JWT access token profile where possible. Opaque tokens need introspection (RFC 7662) — cache results briefly — but revocation is immediate.
Token formats
| Criterion | JWT access token | Opaque token + introspection |
|---|---|---|
| Validation | Local (signature + claims) | Call to authorization server |
| Latency | Low | Network hop (cacheable) |
| Revocation | Waits for expiry (keep short) | Immediate |
| Token contents visible to client | Yes (base64) | No |
| Size | Large | Small |
| Best for | High-throughput APIs, microservices | Sensitive APIs needing instant revocation |
jwks = jwt.PyJWKClient("https://auth.example.com/.well-known/jwks.json", cache_keys=True)
def verify(token: str) -> dict:
key = jwks.get_signing_key_from_jwt(token).key
claims = jwt.decode(token, key, algorithms=["RS256"], # pinned
audience="https://api.example.com", issuer="https://auth.example.com/",
leeway=30, options={"require": ["exp", "iat", "sub"]})
if "orders:read" not in claims.get("scope", "").split():
raise Forbidden("insufficient_scope")
return claims
import { createRemoteJWKSet, jwtVerify } from "jose";
const JWKS = createRemoteJWKSet(new URL("https://auth.example.com/.well-known/jwks.json"));
export async function verify(token: string) {
const { payload } = await jwtVerify(token, JWKS, {
issuer: "https://auth.example.com/", audience: "https://api.example.com",
algorithms: ["RS256"], clockTolerance: 30,
});
return payload;
}
@Bean SecurityFilterChain api(HttpSecurity http) throws Exception {
return http.authorizeHttpRequests(a -> a
.requestMatchers(HttpMethod.GET, "/v1/orders/**").hasAuthority("SCOPE_orders:read")
.anyRequest().authenticated())
.oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults())) // issuer-uri + audience validator in config
.build();
}
Common pitfalls
- Not checking
aud(tokens for another API accepted). - Accepting the algorithm from the token header.
- Decoding JWTs without verifying them (e.g. in a gateway plugin).
Key points
- Verify signature with pinned algorithms
- Check iss, aud, exp and scopes
- Short TTLs or introspection for revocation
Further reading: RFC 9068: JWT Profile for OAuth 2.0 Access Tokens
Practice questions
Why must a resource server check the aud claim?
Answer
Otherwise a token issued for a different API (perhaps a less trusted one) could be replayed against yours.
What is the main trade-off between JWT and opaque access tokens?
Answer
JWTs validate locally and fast but can't be revoked before expiry; opaque tokens need introspection (latency) but revoke immediately.
Open in the app to mark lessons complete, take notes and track your progress.