Why I wrote this: Building the JWT Decoder on aijsons.com forced me to understand authentication at a level tutorials skip. I needed to decode tokens client-side without sending them to a server — which meant learning exactly what is inside a JWT, what can be trusted, and what cannot. Since then, I have integrated with Stripe, GitHub, internal microservices, and third-party SaaS APIs — each with a different auth model. This article is the decision framework and debugging playbook I use when authentication fails in production.
Three Models, Three Use Cases
Most REST APIs authenticate requests using one of three patterns. Choosing the wrong one creates security debt or unnecessary complexity:
| Model | Best For | Header Format |
|---|---|---|
| API Key | Server-to-server, long-lived access, developer tools | Authorization: Bearer sk_live_... or X-API-Key: ... |
| Bearer Token (opaque) | OAuth access tokens, session tokens validated server-side | Authorization: Bearer <opaque string> |
| JWT | Stateless auth, microservices, short-lived delegated access | Authorization: Bearer eyJhbG... |
The critical distinction: an API key identifies your application. A Bearer token (opaque or JWT) identifies a user or delegated session. Mixing these roles — putting a user JWT in a mobile app where an API key belongs, or using a long-lived API key in a browser — is how credentials leak.
API Keys: Simple, Dangerous When Mishandled
Where to Send the Key
Always in a request header, never in the URL query string. Query parameters appear in server access logs, browser history, and referrer headers when users click external links. Stripe, AWS, and GitHub all reject or warn against query-string keys for this reason.
# Correct — key in header (use env var, never hardcode)
curl -H "Authorization: Bearer ${STRIPE_SECRET_KEY}" \
https://api.stripe.com/v1/charges
# Wrong — key ends up in logs
curl "https://api.example.com/data?api_key=secret123"
Key Rotation Without Downtime
When I rotate production API keys, I use a two-key window:
- Generate new key in the provider dashboard
- Deploy config with both old and new keys accepted on the server (or update client first if you control only the client)
- Verify traffic on the new key via metrics
- Revoke the old key after 24–48 hours
Skipping step 2 caused a 15-minute outage on a side project when I revoked the old key before the CI pipeline finished deploying the new one.
Environment Separation
Prefix or color-code keys mentally: sk_test_ vs sk_live_, ghp_test_ vs production tokens. I store them in separate environment files (.env.staging, .env.production) and add a startup check that refuses to boot if a live key is detected in a non-production environment.
Bearer Tokens: Opaque vs Self-Contained
An opaque Bearer token is a random string meaningful only to the authorization server. Your API receives Authorization: Bearer a8f3c2..., calls the auth server's introspection endpoint (RFC 7662), and gets back {"active": true, "scope": "read:users", "sub": "user_123"}.
When opaque wins: instant revocation (delete the session server-side), no sensitive claims exposed to clients, simpler rotation.
When JWT wins: high-throughput microservices that cannot call an introspection endpoint on every request — the token carries verified claims locally.
Real incident: A mobile app stored the OAuth access token in localStorage. Any XSS vulnerability exfiltrated it. Moving to HTTP-only secure cookies for the session token eliminated the attack surface — the Bearer token never touched JavaScript.
JWT: Structure, Validation, and What Can Go Wrong
Anatomy of a JWT
A JSON Web Token has three Base64url-encoded parts separated by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. ← header {"alg":"HS256","typ":"JWT"}
eyJzdWIiOiJ1c2VyXzEyMyIsImV4cCI6MTc0... ← payload {"sub":"user_123","exp":174...}
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQ... ← signature (binary, base64url)
Paste any token into our JWT Decoder to inspect the header and payload without sending it to a server. This is my first step on every 401 — before touching server logs.
The Validation Checklist (Server-Side)
Decoding is not validation. A JWT can be decoded by anyone; only the server (or services sharing the secret/public key) can verify it was issued legitimately. Every JWT middleware must check:
- Signature — verify with the correct key and algorithm
exp— token not expired (with small clock skew tolerance, e.g. 30 seconds)nbf— token is already valid (if present)iss— issuer matches expected value (e.g.https://auth.mycompany.com)aud— audience includes this API's identifieralg— algorithm is on an explicit whitelist (never trust the header alone)
// Node.js with jose — production-safe defaults
import { jwtVerify, createRemoteJWKSet } from 'jose';
const JWKS = createRemoteJWKSet(new URL('https://auth.example.com/.well-known/jwks.json'));
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
algorithms: ['RS256'], // whitelist — never accept 'none' or 'HS256' here
});
Algorithm Confusion: The Most Dangerous JWT Bug
If your API uses RS256 (asymmetric — public key verifies, private key signs) but your library accepts HS256 when the attacker sets "alg": "HS256" in the header, the attacker can sign a forged token using the public key as the HMAC secret. Public keys are often published at /.well-known/jwks.json.
Fix: hardcode the allowed algorithms in verification code. Reject tokens where header alg does not match. Never use jwt.decode() without jwt.verify() in production paths. Our JSON security guide covers additional JWT attack vectors in depth.
What Belongs in the Payload
JWT payloads are Base64-encoded, not encrypted. Anyone holding the token can read every claim. Safe claims: sub (user ID), exp, scope, role. Never put passwords, credit card numbers, or PII you would not show in a URL bar.
For sensitive session data, use opaque tokens and store state server-side — or use encrypted JWTs (JWE) if you truly need self-contained encrypted payloads, though that adds significant complexity.
Debugging Auth Failures: My 5-Minute Workflow
When an authenticated request returns 401, I run this sequence before opening server logs:
- Decode the JWT in the JWT Decoder — check
exp,iss,aud - Compare clock time — is
expin the past? Server clock drift? - Verify environment — staging token against production endpoint?
- Check header format —
Authorization: Bearer <token>with exactly one space, no quotes around the token - Reproduce with curl — isolate from client framework bugs
# Minimal repro — replace TOKEN and URL
curl -sv -H "Authorization: Bearer TOKEN" \
-H "Content-Type: application/json" \
https://api.example.com/v1/me 2>&1 | grep -E '^< HTTP|< www-authenticate|^\{'
# Common mistake: "Bearer" missing
curl -H "Authorization: TOKEN" ... # → 401
# Common mistake: extra quotes
curl -H 'Authorization: Bearer "eyJ..."' ... # → 401
If curl succeeds but the application fails, the bug is in how the client attaches the header — not the token itself. I have seen Axios interceptors double-prefix Bearer Bearer after a refactor.
OAuth 2.0: Which Flow for Which Client
OAuth is how you obtain Bearer tokens; it is not an alternative to JWT or API keys. Quick reference:
- Authorization Code + PKCE — user-facing web and mobile apps (the only acceptable flow for public clients)
- Client Credentials — server-to-server, no user context (microservice calling microservice)
- Device Code — CLI tools and smart TVs where browser redirect is awkward
Never use Implicit flow — deprecated and insecure. Never put client secrets in mobile apps — they will be extracted from the APK/IPA.
Production Security Checklist
- [ ] API keys only in headers, never query strings or client-side code
- [ ] Separate keys per environment; startup guard against live keys in dev
- [ ] JWT: whitelist algorithms, verify signature + exp + iss + aud
- [ ] JWT: no sensitive data in payload; short expiry (15–60 min access tokens)
- [ ] Refresh tokens stored HTTP-only, secure, SameSite=Strict
- [ ] Log authentication failures with request ID, never log full tokens
- [ ] Rate-limit failed auth attempts per IP and per credential
- [ ] Rotate keys on schedule and immediately on suspected leak
How This Connects to HTTP Status Codes
Authentication failures surface as HTTP status codes — but the code alone is not enough. A 401 with WWW-Authenticate: Bearer error="invalid_token", error_description="Token expired" tells you exactly what to fix. A 403 after successful auth means your token is valid but lacks the required scope. I covered the full status code decision tree in the HTTP status codes field guide; use it together with this authentication checklist.
Key Takeaways
- API keys identify applications; Bearer tokens identify sessions — do not swap their roles.
- Never put credentials in URL query strings or browser-accessible storage.
- JWT decode ≠ JWT verify — always validate signature, expiry, issuer, and audience server-side.
- Whitelist JWT algorithms explicitly; algorithm confusion is a critical vulnerability.
- On 401, decode the token locally first, then curl, then server logs — in that order.
- Use OAuth Authorization Code + PKCE for user-facing apps; Client Credentials for service-to-service.