Index Ai Academy
Phase 8 · APIs · Security & reliability

Authentication and authorization

API keys, sessions, tokens and OAuth: who's calling, and what may they do?

About 45 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. Two questions
  2. Sessions, for browsers
  3. A JWT is signed, not secret
  4. The checks you must never skip
  5. Authorization: scopes, roles and ownership
  6. Decoded isn't verified
  7. 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]))
It runs right here in your browser, with nothing to install.
Quick check
Why is it fine to store API keys with fast SHA-256, when passwords need slow hashes?
  1. API keys are long random strings, so even billions of guesses per second never find one
  2. SHA-256 is slow enough already
  3. It isn't fine; API keys need bcrypt
  4. API keys never leak
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 (or Strict): 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.

Quick check
What does this print?
import base64, json
payload = base64.urlsafe_b64encode(json.dumps({"sub": "ada"}).encode()).decode()
print(json.loads(base64.urlsafe_b64decode(payload))["sub"])
  1. an error
  2. ada
  3. sub
  4. None
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.

Quick check
Anyone can decode a JWT and read its payload. What stops them changing "role": "user" to "role": "admin"?
  1. The payload is encrypted
  2. Browsers block edits to tokens
  3. Nothing; JWTs can't be trusted
  4. The signature: the server rejects a token whose payload no longer matches it
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.

Worked example

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)))
In the lesson you can change this and run it.

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:

  1. The token has exactly three parts, and the header's alg is the one you expect. Never let the token choose. Old libraries accepted "alg": "none", meaning no signature at all.
  2. The signature matches, compared with hmac.compare_digest.
  3. exp is in the future.
  4. aud is your API. A token issued for another service mustn't work on yours.
  5. Only then, read sub and scope and authorize.
Quick check
A JWT's signature is valid, but its aud is billing.shop.test and your API is api.shop.test. What should your API do?
  1. Accept it, because the signature proves it's genuine
  2. Reject it, because it was issued for another service
  3. Accept it, but allow only read requests
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:

  1. The app sends you to the provider's login page, asking for specific scopes.
  2. You log in there and approve. The provider redirects back to the app with a short-lived code.
  3. The app's server swaps the code for an access token (short-lived) and a refresh token.
  4. 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.

Try it now

The app gets limited access, and it never sees your password.

Quick check
A user with a valid token requests GET /invoices/inv_77, which belongs to another company. What should happen?
  1. 200: the token is valid, so the request is allowed
  2. 401: the token must be wrong
  3. 500
  4. 403, or 404 so the id isn't confirmed: the query must also check the invoice's owner
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

Create a free account

Try a quiz question

A request arrives with no credentials at all. Which status fits?

  1. 401 Unauthorized
  2. 403 Forbidden
  3. 404 Not Found
  4. 400 Bad Request

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson