What you'll learn
How a client proves who it is to a REST API β and how to pick the right mechanism for each kind of caller. HTTP already defines an authentication contract; the job is to use it well and know the cost of every deviation.
The one rule: the Authorization header should almost always win. It's the standard the whole web β caches, proxies, CORS, client libraries β already understands. Custom headers work, but you pay for them.
Based on Hector Reyes' article Authentication Headers in REST APIs, and mapped onto the platform's Lambda Authorizer (Part 2).
The HTTP authentication contract
HTTP has a built-in handshake. When a request lacks valid credentials, the
server answers 401 Unauthorized with a
WWW-Authenticate header naming the scheme; the client retries
with an Authorization header. That's the whole model β and
every deviation from it has a cost (caching, CORS, tooling).
# server β client
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api"
# client β server
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
Pick the scheme by who is calling
Don't ask "which auth is best?" β ask "who is the caller?" Each answer points at one mechanism:
| Caller | Use | Header |
|---|---|---|
| Browser user | Cookie session | Set-Cookie: β¦ HttpOnly; Secure; SameSite=Strict |
| Service β service | Bearer JWT (short-lived) | Authorization: Bearer <jwt> |
| Third-party developer | Static API key | Authorization: ApiKey ak_live_β¦ |
| Webhook sender | HMAC signature | X-Hub-Signature-256: sha256=β¦ |
The core trade-off: a JWT has a bounded damage window β it expires in minutes, so a leak self-heals. A static API key is unbounded until you revoke it β simpler for developers, riskier if it leaks. Freshness vs. operational simplicity.
Authorization: Bearer (JWT)
The RFC 6750 standard: an opaque token the server decodes and validates. Pair it with a short-lived JWT β validate the signature, the expiry, and the audience so a token minted for one service can't be replayed against another:
import jwt
decoded = jwt.decode(
token, secret,
algorithms=["HS256"], # or RS256/ES256 with a public key
audience="internal-api", # reject tokens minted for another service
options={"require": ["exp", "iat", "sub"]},
)
- Always over HTTPS, always validate the signature β never trust an unverified token.
- Check
audto stop cross-service replay; prefer asymmetric keys (RS256/ES256) so verifiers hold only a public key. - Short expiry (15 minβ1 h) bounds the blast radius of a leak β at the cost of refresh infrastructure.
Authorization: Basic
Base64 of username:password β encoded, not encrypted. Simple, and fine
for internal tooling or local dev, but the credential is a long-lived password that's
painful to rotate.
Authorization: Basic dXNlcjpwYXNzd29yZA== # base64("user:password")
Never use Basic auth over plain HTTP. Base64 is trivially reversible β without TLS you're sending the password in the clear. Reserve it for internal/dev, and prefer a token everywhere else.
API keys: Authorization: ApiKey vs X-API-Key
Both carry a static key; the difference is where. Keeping it in the standard
Authorization header (a custom scheme) is far cheaper than a custom
header:
Authorization: ApiKey ak_live_abc123xyz # custom SCHEME β standard header (Stripe, Twilio)
X-API-Key: ak_live_abc123xyz # custom HEADER β infrastructure cost
X-API-Keyis not treated as private by caches β HTTP caching infrastructure doesn't know it's a credential.- CORS must list it explicitly, and client libraries/tooling don't understand it natively β developers must read your docs instead of using a standard.
- Acceptable when a gateway owns it β AWS API Gateway uses
X-API-Keynatively and enforces it before your code runs. Otherwise, prefer theAuthorization: ApiKeyscheme.
Cookie-based sessions
For a browser, the browser is the credential store: it sends the session cookie automatically on same-origin requests. The security is entirely in the flags:
Set-Cookie: session=<token>; HttpOnly; Secure; SameSite=Strict; Path=/api; Max-Age=3600
HttpOnlyβ invisible todocument.cookieand every JS API, so XSS can't steal it.Secureβ sent over HTTPS only.SameSite=Strictβ never sent cross-origin, which kills CSRF.Max-Ageβ a real expiry the browser enforces.
Cookies don't compose with machines. A Lambda or a service can't manage a cookie jar β so cookies are for browsers, and everything server-to-server uses a token in the Authorization header.
Issuing static API keys safely
Generate with real entropy, hand the key out once, and store only its hash β exactly like a password:
import secrets
def generate_api_key() -> str:
return "ak_live_" + secrets.token_urlsafe(32) # 32+ bytes of entropy
# store only the HASH in the DB; look it up to authenticate, delete it to revoke.
# rate limits and scopes attach to that DB record.
Never store the plaintext key. Hash it like a password β a DB leak then exposes nothing usable. Real-time revocation is just deleting the row; there's no refresh dance, but the key is long-lived, so treat a leak as a rotate-now event.
HMAC signatures β verifying webhook senders
An inbound webhook has no session and no token you issued. Instead the sender and you share a secret; it signs the raw body with HMAC-SHA256 and sends the signature in a header. You recompute and compare β in constant time:
import hmac, hashlib
def verify_webhook(payload: bytes, signature_header: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(f"sha256={expected}", signature_header)
Use hmac.compare_digest, not ==. A normal string compare returns early on the first mismatched byte, leaking timing that lets an attacker recover the signature byte-by-byte. Constant-time comparison closes that. Verify before processing, and make the handler idempotent β senders retry.
Parse the Authorization header safely
Most auth bugs are parsing bugs. HTTP headers are case-insensitive, and a token can contain characters you don't expect β so look up case-insensitively, split on the first space only, and reject malformed input distinctly from missing input:
auth = headers.get("authorization") or headers.get("Authorization")
if not auth:
raise Unauthorized("missing Authorization header") # 401, WWW-Authenticate
scheme, _, credential = auth.partition(" ") # split on the FIRST space only
if not credential:
raise Unauthorized("malformed Authorization header") # distinct from missing
if scheme.lower() == "bearer":
... # case-insensitive scheme
How the platform does all three at once
On the platform, a single Lambda Authorizer sits in front of every API and accepts three credential types β then hands the service one clean identity:
Authorization: Bearer <jwt> β
x-api-key: ak_live_β¦ βββΆ Lambda Authorizer ββΆ { org_id, username, auth_type }
x-signature: sha256=β¦ (HMAC) β validate Β· resolve org injected into requestContext
- One boundary, many schemes β browser JWTs, machine API keys, and n8n HMAC callbacks all authenticate at the same place; auth is mandatory, and a request with no valid credential gets a
401. - Identity, resolved once β the authorizer turns whichever credential into an
org_id+username, and every downstream query is scoped by that org (Parts 5 & 6). - Least privilege β keys are hashed and revocable, JWTs are short-lived and audience-checked, HMAC secrets live in Secrets Manager (Part 2).
The org_id: derive it, don't trust it
Multi-tenant APIs need to know which organization a request belongs to β and
it's tempting to let the client send it as an X-Org-Id header (or an
org_id in the body). Don't. A client can set any value, so
trusting it is a one-line tenant-isolation breach: change the header, read another
org's data. The org id must come from the verified credential, resolved
server-side by the authorizer:
# the authorizer derives org_id from the credential β never from a client header
if auth_type == "jwt":
org_id = claims["app_metadata"]["org_id"] # a signed JWT claim
elif auth_type == "api_key":
org_id = api_key_row.org_id # the key's owner row in the DB
# β injected into requestContext.authorizer; the service reads it THERE, not from headers
If you must accept an X-Org-Id β say a user who belongs to several orgs picking the active one β the authorizer must validate it against the orgs that credential is allowed and reject anything else. The header selects among permitted orgs; it never grants one. The service still reads the resolved org_id from requestContext, not from the raw header.
A CLAUDE.md for API auth
# CLAUDE.md β Auth
## The rule
- Credentials go in the Authorization header. 401 + WWW-Authenticate on failure.
- Pick by caller: browserβcookie, serviceβBearer JWT, 3rd-partyβApiKey, webhookβHMAC.
## Do
- JWT: HTTPS, verify signature + exp + audience; prefer RS256/ES256; keep it short-lived.
- API keys: 32+ bytes entropy, store the HASH only, revoke via DB, rate-limit per key.
- Cookies (browser only): HttpOnly + Secure + SameSite=Strict + Max-Age.
- Webhooks: HMAC-SHA256, verify with hmac.compare_digest (constant time), be idempotent.
- Parse headers case-insensitively; split on the FIRST space; reject malformed β missing.
## Don't
- No Basic auth over plain HTTP. No secret behind X-API-Key when Authorization works.
- Never store plaintext keys. Never compare signatures with ==.
Where this fits: the credentials here are validated at the platform's authorizer (Part 2), scope the SAM (Part 5) and container (Part 6) services by org, and are the secrets your pipeline (Part 9) keeps out of the repo. Full source: Authentication Headers in REST APIs.