API Design

Security · Lesson 08

API keys, JWTs & a hardening checklist

API keys are the simplest credential; JWTs are the most portable. Together they cover the majority of real-world API authentication — but both carry traps that are easy to walk into and expensive to clean up.

⏱ 14 min Difficulty: core Prereq: AuthN vs AuthZ (Lesson 05), OAuth (Lesson 06)

By the end you'll be able to

API keys: labelling the caller, not the person

Think of an API key like a vehicle registration plate. It tells you which vehicle is on the road (which application is calling), but says nothing about who is driving. API keys identify a client application or service account, not an individual human. That distinction matters: if you grant a key write access to your entire database, every user of that application — and every attacker who steals the key — has the same wide-open door.

Keys are best suited for: server-to-server calls where a service needs to identify itself; public-facing read-only APIs where you want usage tracking without user accounts; and simple integrations where OAuth would be engineering overkill.

PropertyIdeal practice
FormatCryptographically random, ≥ 128 bits of entropy (e.g. 32-byte hex or URL-safe base64).
Storage (server side)Hash the key before persisting (bcrypt or SHA-256 + salt), just like a password. Store only the hash.
Storage (client side)Environment variables or a secrets manager (Vault, AWS Secrets Manager). Never in source code or VCS.
ScopeAssign least-privilege permissions to each key. A read-only key cannot mutate data.
RotationSupport key rotation without downtime — allow two keys to be valid in parallel briefly, then retire the old one.
TransmissionAlways over TLS. Usually in the Authorization header, never in the URL query string.

JWTs: a signed statement about the caller

A JSON Web Token (JWT, pronounced "jot") is three Base64URL-encoded segments joined by dots: header.payload.signature. The server that issues it signs it; any server that holds the corresponding verification key can validate it without consulting a shared database. This is what makes JWTs useful for distributed systems.

Header alg + typ . Payload claims (sub, exp, iat…) . Signature HMAC-SHA256(header+payload) signed content verifies integrity eyJhbGciOiJIUzI1NiJ9 · eyJzdWIiOiJ1c2VyXzQyIn0 · SflKxwRJSMeKKF2QT4fwpMeJf36P header (encoded)
The signature covers both header and payload. Tampering with any claim invalidates the signature — the verifier detects this immediately, with no database lookup.

Worked example: decoding a JWT

# A raw JWT (three dot-separated segments)
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzQyIiwibmFtZSI6IkFkYSBMb3ZlbGFjZSIsInJvbGUiOiJlZGl0b3IiLCJpYXQiOjE3NTAwMDAwMDAsImV4cCI6MTc1MDAwMzYwMH0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

# Decoded header
{
  "alg": "HS256",   // signing algorithm
  "typ": "JWT"
}

# Decoded payload — signed, NOT encrypted; anyone can read this
{
  "sub":  "user_42",        // subject — who this token is about
  "name": "Ada Lovelace",   // custom claim
  "role": "editor",         // custom claim — used for authZ checks
  "iat":  1750000000,       // issued at (Unix timestamp)
  "exp":  1750003600        // expires in 1 hour — always set this
}

# Server verification pseudocode
header, payload, sig = jwt.split(".")
if not verify_signature(header, payload, sig, secret_key):
    return 401  # tampered or wrong key
if payload.exp < now():
    return 401  # expired
return payload  # trusted claims

JWT pitfalls

JWT's elegance hides several sharp edges that have caused real-world breaches:

  1. The alg=none attack. Early JWT libraries accepted a token with "alg": "none" and no signature — effectively a self-signed "trust me" token. Fix: explicitly specify the expected algorithm; never accept none.
  2. Payload is signed, not encrypted. Base64URL is reversible. Any secret in the payload — an email address, a role, PII — is readable by anyone who intercepts the token. For sensitive claims use JWE (JSON Web Encryption) or keep them out of the token entirely.
  3. Hard to revoke. A valid token is accepted by any server until it expires. If you need to invalidate a token before expiry (user logs out, account suspended) you must maintain a block-list — which reintroduces state. Design for short expiry (15–60 min) to minimise the revocation window.
  4. Missing or huge expiry. A token without an exp claim is valid forever. Set expiry; use refresh tokens for long-lived sessions.

Refresh strategy

Keep access tokens short-lived (15–60 minutes). Issue a longer-lived refresh token (hours to days, depending on risk tolerance) stored in an HttpOnly cookie. When the access token expires, the client silently exchanges the refresh token at the token endpoint for a new access token. If the refresh token is revoked (logout, security event), the session ends. This balances usability (no re-login) with security (short blast radius on a stolen access token).

API hardening checklist

The items below are not theoretical — each has been the root cause of a published API breach. Work through them as a pre-launch review or a periodic security audit.

#ControlWhy it matters
1Enforce TLS everywhere, no HTTP fallback.Any credential or token sent in cleartext is trivially intercepted. HSTS headers prevent downgrade attacks.
2Authenticate every request; authorize per object.AuthN at the endpoint level alone leaves BOLA/IDOR gaps. Verify the caller owns the specific resource they are accessing.
3Validate and sanitise all input.Schema-validate request bodies, enforce max lengths, reject unexpected fields. Prevents injection, oversized payloads, and unexpected state mutations.
4Rate limit every endpoint.Without rate limiting, credential stuffing, enumeration, and denial-of-service are trivial. Apply limits per IP and per authenticated user.
5Least-privilege scopes and roles.Each token or API key should only carry the permissions it actively needs. Minimises blast radius on compromise.
6Rotate secrets; use a secrets manager.API keys and signing secrets in environment variables are a start — but a secrets manager (Vault, AWS SM) provides audit trails, versioning, and automatic rotation.
7Log and monitor auth events.Log every 401, 403, and token issuance. Alert on anomalies (spike in 401s = credential stuffing; spike in 403s on a single resource = BOLA probe).
8No secrets in URLs.Query strings appear in server logs, browser history, and Referer headers. Credentials go in the Authorization header, never in the URL.
9Set JWT expiry; never accept alg=none.Short-lived tokens shrink the revocation problem. Explicit algorithm validation prevents trivial signature bypass.
10Return minimal error detail.Stack traces, database errors, and field-level validation messages in API responses help attackers enumerate your system. Return user-friendly messages; log the detail server-side.
🎯 Interview angle

"How would you secure this API?" is one of the most common system-design prompts. A senior answer covers all three layers: transport (TLS), identity (authN with expiring tokens), and authorization (authZ including per-object checks). Then add operational hygiene: rate limiting, log/monitor, least privilege. Candidates who only mention "use HTTPS and JWT" are answering a third of the question.

⚠️ Common trap

Putting an API key in a URL. It is tempting because it works: GET /data?api_key=sk_live_abc123. But the key now lives in server logs, browser history, proxy logs, and any Referer header sent to third-party resources on the page. Move it to Authorization: Bearer sk_live_abc123 — headers are not logged by default and are not forwarded in Referer.

✅ Do this, not that

Do hash API keys before storing them in your database, exactly as you would hash passwords. If your keys table is exfiltrated, attackers get hashes, not working keys. Don't store them in plaintext "because they are already random." The cost of hashing is negligible at issuance time; the protection is permanent.

Under the hood: how the signature is computed and verified

The word "signature" hides the whole mechanism. Here is exactly what happens. The signing input is the two encoded segments joined by a dot — base64url(header) + "." + base64url(payload) — call it the signing input. The algorithm in the header decides how that input becomes a signature.

HS256 (symmetric, HMAC-SHA256). Issuer and verifier share one secret:

signing_input = base64url(header) + "." + base64url(payload) signature = HMAC_SHA256(signing_input, SECRET) token = signing_input + "." + base64url(signature) # To verify, the server RECOMPUTES the HMAC over the received # signing_input with its own copy of SECRET, then compares: constant_time_equals(recomputed, provided_signature) → accept any byte of header or payload changed → HMAC differs → reject

That comparison is why tampering fails: flip a single character of the payload (say "role":"editor""admin") and the attacker can't produce a matching HMAC without the secret, so the recomputed signature won't equal theirs. The compare must be constant-time so timing can't leak how many bytes matched. The secret never travels in the token.

RS256 (asymmetric, RSA). The issuer signs with a private key; anyone verifies with the matching public key. This is the one that matters for distributed systems: a dozen services (or a third party) can verify your tokens with the public key while none of them can forge one, because forging needs the private key. With HS256 every verifier holds the secret — and any of them could mint tokens. Use RS256 (or ES256) the moment more than one party verifies.

Key rotation — kid and JWKS. The header carries a kid (key id). The verifier fetches the issuer's public keys from a JWKS endpoint (a JSON document of keys, e.g. /.well-known/jwks.json), looks up the key whose id matches kid, and verifies with it. That indirection lets the issuer roll keys: publish the new key, start signing with its kid, retire the old one once tokens expire — verifiers follow along automatically.

⚠️ The algorithm-confusion attack (deeper than alg=none)

If a server is written to "verify with whatever alg the token says," an attacker can take a system that uses RS256, craft a token with "alg":"HS256", and sign it using the server's public RSA key as if it were an HMAC secret. The public key is, by definition, public — so the attacker can compute a valid HMAC, and a naive verifier accepts a forged token. Fix: pin the expected algorithm(s) server-side and never let the token choose. This is the same root cause as alg=none: trusting attacker-controlled header fields.

How to debug & inspect it

A JWT is not encrypted — it's three base64url chunks — so you can read it with nothing but a shell. (Decode to inspect; never paste a production token into a web tool.)

$ TOKEN="eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyXzQyIiwiZXhwIjoxNzUwMDAzNjAwfQ.SflKxw..." $ echo "$TOKEN" | cut -d. -f1 | base64 -d 2>/dev/null {"alg":"HS256","typ":"JWT"} $ echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null {"sub":"user_42","exp":1750003600} # base64url uses -_ instead of +/ and drops padding; for strict decoders, # translate first: tr '_-' '/+' and pad with '='. Or use a `jwt` CLI.

When verification fails, the symptom usually points straight at the cause:

SymptomLikely causeFix
401 "invalid signature"Wrong/rotated key, token from a different issuer, or server expects a different alg than the token usesConfirm the key (match kid against JWKS) and that the pinned algorithm matches the issuer's
401 "token expired"exp has passed — or the client/server clocks disagreeRefresh the token; allow a small clock-skew leeway (e.g. 30–60s) on exp/nbf
"jwt malformed"Not three dot-separated base64url segments (truncated, double "Bearer", URL-encoded)Log the raw header value; strip exactly one Bearer  prefix
Valid on one service, rejected on anotherVerifier hasn't picked up the new signing keyEnsure all verifiers fetch/refresh the JWKS and honour kid
A token that should be rejected is acceptedalg=none or algorithm-confusion; expiry not checkedPin algorithms; confirm exp validation is on

Debug checklist: decode header + payload (above) → check exp/iat/nbf against the current time → confirm the server's pinned alg matches the header → verify the signature with the right key (by kid) → check for clock skew and a stray Bearer prefix.

In production: how leading APIs authenticate

Two approaches dominate real-world API authentication: the bearer key, where the secret itself is the credential, and the signed request, where a derived signature proves possession of the secret without transmitting it. Stripe and AWS represent the clearest public contrast between the two.

API / ProviderCredential modelSecret transmitted?Body integrity protected?
Stripe Bearer key — the secret key (sk_live_…) is sent in the Authorization: Bearer header on every request, over TLS. Restricted keys (rk_live_…) carry narrower permissions but are transmitted the same way. Yes — the key travels over TLS on every call. TLS confidentiality is the only protection against interception. No — the request body is not signed. An in-path attacker who could break or intercept TLS could modify the payload undetected. In practice, TLS makes this acceptable for most use cases. See Stripe API keys docs.
AWS Signature Version 4 (SigV4) — the client builds a canonical request (method, URI, headers, body hash), derives a signing key via an HMAC chain (secret → date → region → service → aws4_request), and signs the canonical string. The signature travels in the Authorization header alongside the Access Key ID and a header list. No — the secret key is used to derive the signing key but never appears in the request. Only the Access Key ID (a non-secret identifier) is transmitted. Yes — the SHA-256 hash of the request body is included in the canonical request and signed. Any tampering with the body invalidates the signature. See AWS SigV4 docs.

Deep dive: the simplicity–security trade-off between bearer keys and signed requests

Stripe's bearer-key model is easy to understand and easy to use: copy the key, put it in the header, done. The security model is: TLS protects the key in transit; treat the key like a password. This is sufficient for the vast majority of integrations — Stripe's developer experience reflects a deliberate choice to reduce friction over adding cryptographic complexity.

AWS SigV4 trades that simplicity for three additional properties. First, the secret is never transmitted — an attacker who captures network traffic (or a log of request headers) cannot reconstruct it. Second, the signature binds to the exact request: method, URL, headers, and body hash are all covered. A man-in-the-middle who intercepts a valid signed request cannot change even a single query parameter without breaking the signature. Third, the signing key is derived with a date and region baked in — a signature for us-east-1 on a Monday cannot be replayed to eu-west-1 on Tuesday.

The cost is implementation complexity: clients must correctly canonicalize the request, compute the HMAC chain, and handle clock skew (AWS rejects requests with a timestamp more than five minutes old). AWS provides SDKs that do this automatically, which is why most developers never implement SigV4 by hand.

For your own API design, the lesson is: bearer keys are the right default when TLS is reliable and the implementation surface is small; request signing is worth the complexity when secrets must never appear in logs, or when request integrity matters beyond transport-layer confidentiality. Webhooks (where you need to verify the payload came from the sender) often use HMAC signing for exactly this reason.

How leading APIs do it

🧠 Quick check

1. An API key identifies:

API keys are application-level credentials. They tell you which app is calling, but they carry no information about which person is using that app. This is why you should never store PII like "this key belongs to Alice" in the key itself.

2. The JWT payload is Base64URL-encoded. This means:

Base64URL is just an encoding, not encryption. The signature protects integrity (tampering is detectable) but not confidentiality. Do not put sensitive data in a JWT payload unless you use JWE to encrypt the token.

3. The alg=none JWT attack works when:

Buggy JWT libraries that read the algorithm from the token header and then behave accordingly will accept a token with "alg":"none" and no signature — essentially a self-asserted, unsigned identity claim. Always hard-code the expected algorithm server-side.

4. Which item from the hardening checklist directly limits the blast radius when an API key is stolen?

TLS reduces the chance of theft; minimal error detail reduces attacker reconnaissance. But if a key is stolen, the damage is bounded by what that key can do. A key scoped to read:orders cannot delete users — least privilege limits what an attacker can accomplish with it.

✍️ Exercise: spot the insecure JWT usage

Review the following server-side code snippet from an API gateway. List every JWT-related security problem you can find, and for each one state how you would fix it.

// Token validation middleware function validateToken(req, res, next) { const token = req.query.token; // read from URL if (!token) return res.status(401).json({ error: "missing token" }); const payload = jwt.verify(token, config.secret, { algorithms: ["HS256", "none"] }); if (!payload) return res.status(401).json({ error: "invalid" }); // No expiry check req.user = payload; next(); }

Model answer — three bugs:

  1. Token from URL query string. req.query.token puts the credential into server logs, browser history, and Referer headers. Fix: read from req.headers.authorization and strip the "Bearer " prefix.
  2. "none" in the allowed algorithms list. { algorithms: ["HS256", "none"] } allows a forged unsigned token to pass verification. Fix: { algorithms: ["HS256"] } — never include "none".
  3. No expiry check. The comment even acknowledges this. jwt.verify in most libraries does check exp by default, but only if the option ignoreExpiration is false (the default). The comment "No expiry check" suggests this was intentionally disabled at some point, or the developer does not know whether it is active. Fix: explicitly confirm expiry is enforced, or add if (payload.exp < Date.now() / 1000) return res.status(401)... as a belt-and-suspenders guard.

Rubric: Full marks for identifying all three issues with correct fixes. Partial marks for two out of three. Bonus: noting that the error message for an expired token should be the same as an invalid token to avoid oracle attacks that reveal whether a token exists.

Key takeaways

Sources & further reading