Authentication and authorization
API keys, sessions, tokens and OAuth: who's calling, and what may they do?
Create a free account Try part of it here
Why it matters
Every useful API has to answer two questions on every request: who is this, and are they allowed to do this? Getting either wrong is how data leaks. The most common serious API flaw is checking that a user is logged in, without checking that the record is theirs. You'll configure these same mechanisms for model APIs, MCP servers and agents, and in Phase 9 you'll meet them again, guarding remote MCP servers.
What you'll be able to do
- Tell authentication (who is calling) from authorization (what they may do)
- Issue and check API keys safely, storing only their hashes
- Build and verify a signed token (JWT) and know the checks that must never be skipped
- Explain sessions, scopes, object-level checks and the OAuth authorization code flow
What it covers
- Two questions
- Sessions, for browsers
- A JWT is signed, not secret
- The checks you must never skip
- Authorization: scopes, roles and ownership
- Decoded isn't verified
- Expiry
Two questions
Picture a hotel. At the front desk you show your ID, which proves who you are. You get a key card, which opens your room and the gym, but not the room next door. APIs make the same split:
- Authentication (often "authn"): proving who is calling, with a key, a password or a token. That's the ID check.
- Authorization ("authz"): deciding what that caller may do. Read campaigns? Delete them? This particular campaign? That's the key card.
They fail differently. A missing or bad credential is 401 Unauthorized. Despite the name, it's about authentication. A valid credential without permission is 403 Forbidden.
In plain words: 401 means "I don't know who you are". 403 means "I know who you are, and the answer is no".
API keys
An API key is the simplest credential for one program calling another. It's a long random secret that the client sends with every request, usually in a header:
Authorization: Bearer sk_live_51Hx...
Done well, it looks like this:
- Generate keys with
secrets: 32 random bytes or more. - Show the key once, when it's created, and store only a hash of it. A hash is a one-way fingerprint: easy to compute from the key, with no way back. If your database leaks, the keys stay safe. A fast hash like SHA-256 is fine here, unlike for passwords. Keys are long and random, so guessing is hopeless however fast the hash is.
- Keep a short prefix visible (
sk_live_51Hx), so people can tell their keys apart in a dashboard. - Give each key scopes, the list of what it may do. Let owners revoke (cancel) and rotate (replace) keys.
import hashlib, hmac, secrets def new_api_key(): key = "sk_live_" + secrets.token_urlsafe(32) record = {"prefix": key[:12], "hash": hashlib.sha256(key.encode()).hexdigest(), "scopes": ["campaigns:read"]} return key, record # give key to the user once; save only record def check_key(presented, records): digest = hashlib.sha256(presented.encode()).hexdigest() for r in records: if hmac.compare_digest(digest, r["hash"]): return r return None key, record = new_api_key() print("show once:", key[:16] + "...") print("stored: ", record["prefix"], record["hash"][:16] + "...") print(check_key(key, [record]) is not None, check_key("sk_live_guess", [record]))
Show the answer
API keys are long random strings, so even billions of guesses per second never find one
Slow hashing defends weak, human-chosen secrets. A 32-byte random key has far too many possibilities to guess.
Sessions, for browsers
People in a browser don't paste API keys. Think of a coat check instead: you hand over your coat once, get a numbered ticket, and from then on the ticket is all you show.
After someone logs in, the server creates a session: a random id stored on the server, sent to the browser in a cookie. The browser attaches that cookie to every request automatically. Three cookie settings matter:
HttpOnly: page scripts can't read it, so a script injected into the page can't steal it.Secure: only sent over HTTPS.SameSite=Lax(orStrict): not sent on most requests started by other sites. That blocks cross-site request forgery, where a hostile page makes your browser send a request with your cookie.
Signed tokens: JWT
A coat-check ticket means nothing without the rack behind it. A festival wristband is different: a guard at any gate can read it and trust it with no list to check, because it's hard to fake.
A JSON Web Token (JWT, said "jot") is that wristband. It carries claims, which are facts about the caller, signed so nobody can change them. It's three parts joined by dots, header.payload.signature, each written in base64url, a way to write any data as letters and digits that are safe in a URL. Here are the header and the payload, decoded:
{"alg": "HS256", "typ": "JWT"}
{"sub": "user_42", "scope": "campaigns:read campaigns:write", "aud": "api.shop.test", "exp": 1793000000}
sub(subject): who the token is about.aud(audience): which API it's meant for.exp: when it expires, in seconds since 1970 (a Unix timestamp).iat: when it was issued.scope: space-separated permissions.
The signature is an HMAC of header.payload, a fingerprint that takes a secret to make. With HS256, only the server knows that secret. With RS256 or ES256 it's a public-key signature instead, which others can check without the secret.
The server can check a JWT without a database lookup. That's why JWTs are popular. It's also their weakness. A wristband keeps working until it expires, even if the person was thrown out. So keep lifetimes short, often minutes, and pair them with a refresh token: a longer-lived token that can be revoked and is used to get new short ones.
import base64, json payload = base64.urlsafe_b64encode(json.dumps({"sub": "ada"}).encode()).decode() print(json.loads(base64.urlsafe_b64decode(payload))["sub"])
Show the answer
ada
Encoding and decoding gives back the same dictionary, and ["sub"] reads ada. Anyone can read a JWT's payload. It's signed, not secret, so never put secrets in it.
"role": "user" to "role": "admin"?Show the answer
The signature: the server rejects a token whose payload no longer matches it
The signature covers the payload, and only the key holder can make a new one. Never put secrets in a JWT: signed isn't secret.
A JWT is signed, not secret
Anyone can read a JWT's payload. Only the server's secret can make a valid signature.
import base64, json token = ("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." "eyJzdWIiOiJ1c2VyXzQyIiwic2NvcGUiOiJjYW1wYWlnbnM6cmVhZCJ9." "x3Ns7r8OqJmXb3S7cqA2kQ6bP1eGQY0l7rZ0tqf0u9M") def b64url_decode(part): return base64.urlsafe_b64decode(part + "=" * (-len(part) % 4)) header, payload, signature = token.split(".") print(json.loads(b64url_decode(header))) print(json.loads(b64url_decode(payload)))
No key was needed to read it. Base64 is an encoding, not encryption, so never put secrets in a JWT. Decoding is not verifying, either: this signature might be garbage. A server must recompute the signature and compare before trusting a single claim.
The checks you must never skip
Most JWT security bugs are a skipped check. A guard who looks at a wristband's color but not its date lets in last year's crowd. Verify, in order:
- The token has exactly three parts, and the header's
algis the one you expect. Never let the token choose. Old libraries accepted"alg": "none", meaning no signature at all. - The signature matches, compared with
hmac.compare_digest. expis in the future.audis your API. A token issued for another service mustn't work on yours.- Only then, read
subandscopeand authorize.
aud is billing.shop.test and your API is api.shop.test. What should your API do?Show the answer
Reject it, because it was issued for another service
A valid signature proves who issued the token. The aud claim says who it was issued for, and a token meant for another service mustn't work on yours.
Authorization: scopes, roles and ownership
Scopes limit what a credential may do: campaigns:read but not campaigns:write. Roles group permissions for people: viewer, editor, admin.
The check that's forgotten most is object-level. Back at the hotel, your key card is real, and it opens guest rooms. It still shouldn't open room 412 when you're staying in 410. The caller may read campaigns, but is cmp_103 theirs? Without that check, changing the id in the URL shows another customer's data. This is broken object-level authorization (BOLA), first on the OWASP API Security Top 10, a widely used list of the most serious API flaws.
In plain words: being logged in isn't enough. Each time someone asks for a record, check that the record belongs to them.
Every query for a record should include the owner: WHERE id = ? AND account_id = ?.
OAuth: access on someone's behalf
"Let this app read my Google Calendar" is OAuth 2.0. Think of the valet key some cars come with. It starts the car but won't open the trunk, and you never hand over your own keys. In the same way, the app gets limited access and never sees your password. In the authorization code flow:
- The app sends you to the provider's login page, asking for specific scopes.
- You log in there and approve. The provider redirects back to the app with a short-lived code.
- The app's server swaps the code for an access token (short-lived) and a refresh token.
- The app calls the API with the access token, and uses the refresh token for new ones.
PKCE (said "pixy") protects step 2. The app makes a random secret, sends only its hash with step 1, and proves it knows the secret in step 3. It's like keeping one half of a torn ticket: a stolen code is useless without your half. Remote MCP servers use exactly this flow, which Phase 9 builds in detail.
The app gets limited access, and it never sees your password.
GET /invoices/inv_77, which belongs to another company. What should happen?Show the answer
403, or 404 so the id isn't confirmed: the query must also check the invoice's owner
Authentication passed, but object-level authorization must still check ownership. Many APIs answer 404 so outsiders can't confirm which ids exist.
Decoded isn't verified
Reading a letter doesn't tell you who really wrote it. Anyone can decode a JWT. Only checking the signature tells you it's genuine, so never trust the claims before that step. Set the allowed algorithm yourself, too.
Expiry
Keep tokens short-lived. A stolen wristband that expires tonight does far less harm than one that works for a year.
Practice what you just read
- 2 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 6 review cards for spaced repetition
Try a quiz question
A request arrives with no credentials at all. Which status fits?