← Workshops Β· Part 10 Β· Security

API Security: Authentication Headers

Prove who is calling β€” the right way for each caller. Bearer JWTs for services, cookies for browsers, API keys for third parties, HMAC for webhooks β€” and why the Authorization header almost always wins.

πŸ“ Test yourself ↓
18 min readSecurityRead or present

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:

CallerUseHeader
Browser userCookie sessionSet-Cookie: … HttpOnly; Secure; SameSite=Strict
Service β†’ serviceBearer JWT (short-lived)Authorization: Bearer <jwt>
Third-party developerStatic API keyAuthorization: ApiKey ak_live_…
Webhook senderHMAC signatureX-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.

Service β†’ service

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"]},
)
Internal only

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.

Third-party developers

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
Browser users

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
πŸ€–

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.

Static keys

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.

Webhooks

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

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 ==.
Check yourself

Quiz β€” 13 questions

Answer every question, then submit to see your score and the correct answers.

Getting API security right?

JunctionNet designs and audits production API auth every day β€” JWT, API keys, HMAC, Lambda Authorizers, least-privilege IAM. Reach out for help or a team workshop.

βœ‰οΈ Get in touch ← Back to workshops