API Design

API Authentication and Authorization: API Keys, OAuth 2.0 and Scopes

API keys, bearer tokens, OAuth 2.0 flows in plain words, scopes, and common mistakes. Ties back to the Security series.

Lesson 0615 min read#api#oauth#auth#security

On this page

  1. Words used in this lesson
  2. 401, 403 and the thing people mix up
  3. Choice 1: API keys
  4. Choice 2: bearer tokens and JWTs
  5. Choice 3: OAuth 2.0 (delegated access)
  6. Scopes: what the token is allowed to do
  7. Authorization: where APIs actually break
  8. Mistakes I see again and again
  9. Try it
  10. Interview questions
  11. Key takeaways
  12. What's next

In one line: authentication asks "who are you?", authorization asks "what may you do?", and most API breaches come from the second question, not the first. Use API keys for simple server-to-server calls, short-lived bearer tokens for users, OAuth 2.0 (authorization code with PKCE) when one app acts for a user in another service, and check every object and every field on every request.

A login screen is easy to understand. A password goes in, a session comes out. APIs are harder, because there is often no human in the loop. A phone app, a cron job, a partner's server and a script on someone's laptop all call the same URLs. Each of them must prove who it is, and each must be limited to what it needs.

This is lesson 6 of the API design series. Lesson 5 covered retries, webhooks and rate limits. Rate limits need a key to count by, and webhook secrets are a form of authentication, so we are continuing from there. Next, lesson 7 writes all these rules down in an OpenAPI file. If you want the web-app side of passwords and sessions, read Security lesson 3. If you want the deeper story of access control bugs, read Security lesson 4. This lesson is about the API shapes of the same ideas.

What I ran / checked. Code ran on Linux with Node.js v22.22.0, with jsonwebtoken 9.0.3 from npm and otherwise only built-in modules (node:http, node:crypto, global fetch). I pasted real output. Tokens and keys in the output are random and change on every run. Sources I read (own words below): the OWASP REST Security Cheat Sheet (sections on HTTPS, access control, JWT, API keys, HTTP methods); the OWASP OAuth 2.0 Protocol Cheat Sheet (which summarises RFC 9700, the OAuth security best practice, and PKCE); the OWASP API Security Top 10 2023 summary table. Could not reach: rfc-editor.org and datatracker.ietf.org, so I did not read RFC 6749 (OAuth 2.0), RFC 7636 (PKCE), RFC 6750 (bearer tokens), RFC 9700 or the OAuth 2.1 draft myself. Where I mention what they say, I am relying on the OWASP cheat sheet's summary of them, and I say so. The OAuth server in this lesson is a toy I wrote to show the message flow. It is not production code. Do not build your own authorization server for a real product; use a proven one. I did not verify any GitHub star counts.

Words used in this lesson

  • Authentication (authn) is proving who the caller is.
  • Authorization (authz) is deciding what that caller may do.
  • Credential is the secret used to prove identity: a password, an API key, a token.
  • API key is a long random string that identifies an application (or an account) calling your API.
  • Bearer token is a token where whoever holds it (the bearer) can use it. It is sent as Authorization: Bearer <token>.
  • JWT (JSON Web Token) is a token made of three parts: header, payload (claims) and signature. Claims are facts such as the user id and the expiry time.
  • Claim is one piece of information in a JWT, such as sub (subject, the user), exp (expiry), aud (audience, who it is for), iss (issuer, who made it).
  • OAuth 2.0 is a standard that lets a user give one app limited access to their data in another service, without sharing a password.
  • Resource owner is the user. Client is the app that wants access. Authorization server logs the user in and issues tokens. Resource server is the API that accepts tokens.
  • Scope is a named permission on a token, like orders:read.
  • PKCE (said "pixy") is a small addition to the authorization code flow that stops a stolen code from being used.
  • BOLA is Broken Object Level Authorization: the API gives you an object by id without checking that it is yours (also called IDOR).
  • Mass assignment is when an API copies every field from the request into a record, including fields the user should not set (like role).

401, 403 and the thing people mix up

Two status codes, two different questions:

CodeName in practiceMeaningExample
401Unauthenticated"I do not know who you are." Missing, expired or bad credentials.No token, or a token that failed verification.
403Forbidden"I know who you are, and you may not do this."A valid token that lacks the orders:write scope.

The name "Unauthorized" for 401 is an old mistake in HTTP; it really means unauthenticated. Remember it like this: 401 = try logging in again, 403 = logging in again will not help.

A 401 should carry a WWW-Authenticate header that tells the client how to authenticate, such as Bearer realm="api". We will see this in the scopes demo below.

There is a third answer: 404. Sometimes the safest reply to "you may not see this object" is "no such object", so that an attacker cannot learn which ids exist. We will use that trick in the BOLA section.

Choice 1: API keys

An API key is a random string that identifies the calling application. Your customer copies it from a dashboard and puts it in their server's config. Every request carries it, usually in a header:

HTTP
GET /v1/orders HTTP/1.1Authorization: Bearer sk_live_2ffb42f1_Ioq28qSCdjEHJjBuwsZ6UUMKFXIonm9K

(Some APIs use a custom header like X-API-Key. Both work. The Authorization header is the standard place, and many logging tools already know to hide it.)

What the OWASP REST cheat sheet says about keys, in my words: keys help stop public APIs from being farmed (too many calls, big bills), and let you sell access plans. They reduce the damage of denial of service. But when you give them to third parties they are fairly easy to leak, so do not rely on keys alone for sensitive or high-value resources. Return 429 when calls come too fast, and revoke a key if the client breaks the usage agreement.

How to store keys safely

Treat an API key like a password: the database should never hold the usable value. Because a key is long and random (unlike a human password), a plain fast hash such as SHA-256 is enough. You do not need a slow password hash, because nobody can guess 192 random bits.

JavaScript
// keys.mjsimport { randomBytes, createHash, timingSafeEqual } from "node:crypto";const db = new Map(); // keyId -> { hash, scopes, owner }function createKey(owner, scopes) {  const id = randomBytes(4).toString("hex");              // public part, used to look the key up  const secret = randomBytes(24).toString("base64url");   // secret part, 192 random bits  const full = `sk_live_${id}_${secret}`;  db.set(id, { hash: createHash("sha256").update(secret).digest(), scopes, owner });  return full;                                            // shown to the user ONCE}function check(full) {  const m = /^sk_live_([0-9a-f]{8})_([\w-]+)$/.exec(full ?? "");  if (!m) return null;  const rec = db.get(m[1]); if (!rec) return null;  const h = createHash("sha256").update(m[2]).digest();  return timingSafeEqual(h, rec.hash) ? { owner: rec.owner, scopes: rec.scopes } : null;}

Real output:

TEXT
shown once to the user: sk_live_2ffb42f1_Ioq28qSCdjEHJjBuwsZ6UUMKFXIonm9Kwhat the database holds: {  hash: <Buffer e6 e9 ea 3e 63 a9 5c 86 53 05 bc 9c 63 74 e9 6d 32 10 c3 c9 ca 15 ca b2 1a d9 f6 b4 3d b0 c4 42>,  scopes: [ 'orders:read' ],  owner: 'acme'}valid key  -> { owner: 'acme', scopes: [ 'orders:read' ] }tampered   -> nullgarbage    -> null

Three small design wins here:

  • A readable prefix (sk_live_) tells people and secret scanners "this is a secret key", and tells you whether it is a test or live key. Many providers do this.
  • A public id inside the key lets you find the record without scanning every hash.
  • Only the hash is stored. If your database leaks, the attacker gets hashes, not working keys. The cost is that you can never show the key again. Tell users to copy it now.

Other rules that save pain: let each customer create several keys (one per app), give each key scopes (read-only vs read-write), show "last used" in the dashboard, support rotation (two keys valid at once while the customer switches), and never put keys in URLs. URLs end up in logs, browser history and the Referer header. Put them in a header. See Security lesson 5 for how secrets leak.

A key in a browser or mobile app is not a secret. Anyone can open the dev tools or unpack the app and read it. A key identifies an application, not a person. Use keys from servers you control. For code that runs on a user's device, use per-user tokens (next sections).

Choice 2: bearer tokens and JWTs

A bearer token is a credential that works for whoever carries it. There is no extra proof. Like cash: if you drop it, someone else can spend it. That is why they must be short-lived, sent only over HTTPS, and limited in what they can do.

The simplest token is a random string that is also a database key (an "opaque" token). The server looks it up on each request. This is easy to revoke: delete the row.

The other style is the JWT (JSON Web Token), pronounced "jot". It carries its own information. A JWT is three base64url pieces joined with dots: header.payload.signature. Let us make one and open it.

JavaScript
// jwt.mjsimport jwt from "jsonwebtoken";const SECRET = "dev-only-secret-change-me-32bytes!!";const claims = { sub: "user_42", scope: "orders:read",                 aud: "https://api.example.com", iss: "https://auth.example.com" };const token = jwt.sign(claims, SECRET, { algorithm: "HS256", expiresIn: "15m" });const [h, p] = token.split(".").slice(0, 2).map(s => JSON.parse(Buffer.from(s, "base64url")));console.log("header :", h); console.log("payload:", p);
TEXT
token parts: 3header : { alg: 'HS256', typ: 'JWT' }payload: {  sub: 'user_42',  scope: 'orders:read',  aud: 'https://api.example.com',  iss: 'https://auth.example.com',  iat: 1791744000,  exp: 1791744900}

Notice we decoded the payload with no key at all. The payload of a normal JWT is signed, not encrypted. Anyone holding the token can read it. Never put secrets or private data (an email, a card number) in a JWT payload. The signature only proves nobody changed it.

The claims we used:

  • sub: who it is about (the subject, the user id).
  • iss: who issued it.
  • aud: who it is meant for (your API).
  • exp: when it stops being valid (seconds since 1970). iat is when it was issued.
  • scope: what it allows (a common extra claim).

Verifying a JWT: the checklist

Verifying is more than "does the signature match". The OWASP REST cheat sheet lists what to check. In my words:

  1. The token must be integrity protected. Reject the unsecured form {"alg":"none"}.
  2. Choose the algorithm in your own code. Never let the token's header choose how you verify it. (Attackers have used this "algorithm confusion" trick against careless libraries.)
  3. For API access, require and check iss (is this a trusted issuer?), aud (is this token meant for me?), and exp (is it still valid?). The cheat sheet says these three are mandatory in the token profile of RFC 9068. Check nbf (not before) too when present.
  4. A signed JWT usually stays valid until it expires, even if the user logs out. For an early logout or a stolen token, keep a denylist of token ids (jti claim) until they expire, or keep tokens very short.

Here is each check being exercised, with real results:

JavaScript
const opts = { algorithms: ["HS256"], audience: "https://api.example.com", issuer: "https://auth.example.com" };const tryIt = (label, t, o = opts, s = SECRET) => {  try { const d = jwt.verify(t, s, o); console.log(label.padEnd(30), "OK sub=" + d.sub); }  catch (e) { console.log(label.padEnd(30), "REJECTED:", e.message); }};
TEXT
valid                          OK sub=user_42wrong audience                 REJECTED: jwt audience invalid. expected: https://other.example.comwrong secret                   REJECTED: invalid signatureexpired                        REJECTED: jwt expiredalg=none forged                REJECTED: jwt signature is requiredpayload edited                 REJECTED: invalid signatureanyone can read it: user_42

The alg=none token was built by hand: a header with "alg":"none", a payload claiming sub: "admin", and an empty signature. It was rejected, because we told verify which algorithms to accept (algorithms: ["HS256"]). The "payload edited" token changed sub to admin but kept the old signature, so the check failed. The wrong-audience case matters a lot in real systems: a token issued for service A must not be accepted by service B, or one leaked token opens every door.

HS256 or RS256?

HS256 uses one shared secret to sign and to verify. That means every service that can verify tokens can also make tokens. The cheat sheet says to prefer signatures over MACs for this reason: with a shared secret, every service must trust every other, and a break-in at any one service breaks them all. RS256/ES256 use a private key to sign (only the auth server has it) and a public key to verify (every API has it). For more than one service, use the asymmetric kind. In this lesson I use HS256 only because it keeps the code short.

Short tokens and refresh tokens

Because a JWT cannot be taken back easily, make the access token short (5 to 15 minutes). To avoid asking the user to log in every 15 minutes, issue a second, longer-lived refresh token. It is only ever sent to the authorization server, never to your API. The client exchanges it for a new access token. Good practice is refresh token rotation: every refresh gives a new refresh token and invalidates the old one, so a stolen token is noticed when both parties try to use the same old one. I did not run rotation in this lesson. Treat that as the design to learn, then test it with your identity provider.

Where do you keep a token in a browser? Not in localStorage if you can avoid it: any script on the page (including one injected by XSS) can read it. An HttpOnly cookie hides it from scripts but brings CSRF back. See Browser lesson 3 for the trade-offs. For a server-to-server call there is no such problem.

Choice 3: OAuth 2.0 (delegated access)

Now a different problem. You run a calendar app. A user wants your app to read their Google or GitHub data. How? Ask for their password? Terrible: now you have their password, you can do everything, and they cannot take it back without changing it.

OAuth 2.0 solves this. The user logs in at the provider (not in your app) and clicks "Allow". The provider hands your app a token that can only do what the user allowed and can be revoked at any time. Your app never sees the password.

There are four roles:

RoleWhoIn the example
Resource ownerThe userA person with a calendar
ClientThe app asking for accessYour calendar app
Authorization serverLogs in the user and issues tokensThe provider's login/consent service
Resource serverThe API that accepts tokensThe provider's data API

OAuth is often confused with login. OAuth 2.0 is about authorization: "this app may read my calendar". If you want to know who the user is, the standard layer on top is OpenID Connect (OIDC), which adds an ID token. See Full-Stack lesson 22 for OAuth vs OIDC from the interview side.

Which flow (grant) to use

SituationUseNotes
A user logs in through a web, mobile or single-page appAuthorization code + PKCEThe default for all user-facing apps.
A server calls an API as itself, no userClient credentialsThe app has its own id and secret, and gets a token for itself.
A TV or CLI with no good keyboardDevice authorizationThe user types a short code on another device.
Old single-page apps used the implicit flowDo not useThe OWASP cheat sheet marks the implicit grant as deprecated and says it is removed from OAuth 2.1; tokens appear in the URL.
"Give the app my username and password"Do not useThe password grant is also not to be used per the cheat sheet.

I read these two "do not" rules in the OWASP OAuth cheat sheet, which cites RFC 9700 (the current security best practice). I did not read RFC 9700 myself.

The authorization code flow with PKCE, step by step

  1. The client makes a random secret called the code verifier, and hashes it to make the code challenge (SHA-256, then base64url). This is PKCE.
  2. The client sends the user's browser to the authorization server: GET /authorize?response_type=code&client_id=...&redirect_uri=...&scope=...&state=...&code_challenge=...&code_challenge_method=S256.
  3. The user logs in and approves. The server redirects the browser back to the client's redirect_uri with a short-lived code and the same state.
  4. The client checks that state matches what it sent (to block a forged callback). Then it makes a direct back-channel POST /token with the code and the original verifier.
  5. The server hashes the verifier, compares it to the challenge it stored, and if it matches, returns an access token (and perhaps a refresh token).

Why PKCE? The code travels through the browser, where it could be intercepted (by a malicious app on a phone, or a leaked log). Without PKCE, whoever grabs the code can exchange it for a token. With PKCE, they would also need the verifier, which never left the client. The OWASP cheat sheet says PKCE should be used by all clients doing the code flow, and that servers must support it, enforce the verifier when a challenge was sent, and reject a verifier when no challenge was sent (to stop "downgrade" attacks).

I wrote a small toy authorization server and ran the flow, plus the attack. Here is the core of the /token endpoint:

JavaScript
if (u.pathname === "/token" && req.method === "POST") {  let raw = ""; for await (const c of req) raw += c; const f = new URLSearchParams(raw);  const rec = codes.get(f.get("code")); codes.delete(f.get("code"));   // single use  if (!rec || rec.exp < Date.now()) return json(res, 400, { error: "invalid_grant" });  if (rec.challenge) {    if (!f.get("code_verifier") || s256(f.get("code_verifier")) !== rec.challenge)      return json(res, 400, { error: "invalid_grant", error_description: "PKCE check failed" });  }  const at = randomBytes(20).toString("base64url");  tokens.set(at, { user: rec.user, scope: rec.scope });  return json(res, 200, { access_token: at, token_type: "Bearer", expires_in: 900, scope: rec.scope });}

And in /authorize, the toy refuses any redirect_uri that is not an exact match with the registered one. Real output of five scenarios:

TEXT
== 1. normal login with PKCEstate matches: truetoken response: 200 {  access_token: 'HML5Z_...',  token_type: 'Bearer',  expires_in: 900,  scope: 'orders:read'}== 2. attacker steals the code from the redirect and tries to use it (PKCE on)attacker without verifier: { error: 'invalid_grant', error_description: 'PKCE check failed' }attacker with a guessed verifier: { error: 'invalid_grant', error_description: 'PKCE check failed' }== 3. same attack when the app did NOT use PKCEattacker gets a token: 200 Bearer== 4. a code can only be used oncesecond use: invalid_grant== 5. redirect_uri must match exactly400 { error: 'invalid_redirect_uri' }

Read the results:

  • 1: the happy path works, and state came back unchanged.
  • 2: an attacker who stole the code could not use it, with no verifier or a guessed one.
  • 3: when the client skipped PKCE, the same stolen code gave the attacker a token. That is the whole reason PKCE exists.
  • 4: the code is single-use. A second try fails.
  • 5: a redirect to evil.example is refused. If the server accepted any redirect, an attacker could send the code to their own site. The OWASP cheat sheet also warns against "open redirectors" and against http redirect URIs (except a loopback address for native apps).

This toy skips what a real server must do: authenticate the user, show a consent screen, bind codes to a specific client, rate limit, rotate refresh tokens, sign tokens, keep an audit log and much more. Use an existing identity provider or a well-maintained library and spend your time on your API.

Access tokens inside your API

Once the client has the token, it calls your API with Authorization: Bearer <token>. Your API (the resource server) checks it: if it is a JWT, verify it locally as in the checklist; if it is opaque, ask the authorization server ("token introspection") or look it up. The OWASP OAuth cheat sheet adds three sensible rules: restrict a token to the minimum privilege needed, restrict it to one audience (one resource server), and restrict it to the resource and actions it was meant for. Every resource server must check these on every request.

Scopes: what the token is allowed to do

A scope is a short label for a permission, written by the API designer, for example orders:read, orders:write, profile. The user (or admin) grants scopes when they approve the app. The token carries them. The API checks that the token has the scope the endpoint needs.

This is the reply behaviour I built and ran:

JavaScript
function auth(need) {  return (req, res) => {    const m = /^Bearer (.+)$/.exec(req.headers.authorization ?? "");    if (!m) { send(res, 401, { error: "missing token" }, { "www-authenticate": 'Bearer realm="api"' }); return null; }    let c; try { c = jwt.verify(m[1], SECRET, { algorithms: ["HS256"] }); }    catch (e) { send(res, 401, { error: e.message }, { "www-authenticate": 'Bearer error="invalid_token"' }); return null; }    if (!c.scope.split(" ").includes(need)) {      send(res, 403, { error: "insufficient_scope", need },           { "www-authenticate": `Bearer error="insufficient_scope", scope="${need}"` });      return null;    }    return c;  };}// GET needs orders:read, anything else needs orders:write
TEXT
no token, GET                  401 WWW-Authenticate: Bearer realm="api"garbage token, GET             401 WWW-Authenticate: Bearer error="invalid_token"read scope, GET                200 WWW-Authenticate: -read scope, POST               403 WWW-Authenticate: Bearer error="insufficient_scope", scope="orders:write"write scope, POST              200 WWW-Authenticate: -

That is the 401 versus 403 rule in action: no or bad token gives 401; a good token with the wrong scope gives 403 and tells the client which scope is missing. (The Bearer error=... format comes from the bearer token RFC. I did not read that RFC; I used the form I know from it, so check it before you copy the details.)

Design your scopes with care:

  • Name them for what the app wants to do, not for your internal tables. orders:read beats table_orders_select.
  • Keep the list small. Fifty scopes confuse users and developers. Group by resource and by read/write.
  • Never use a scope as the only check. A scope says what the app may do. It does not say what the user may do. If a token has orders:read, the user can still only read their own orders. You need both: scope allows the action, ownership allows the object. That brings us to the biggest section.

Authorization: where APIs actually break

Everything above answers "who is calling". The OWASP API Security Top 10 (2023 edition) shows where real APIs fail. I read the summary table. The first and third entries are about authorization:

OWASP API 2023Plain meaning
API1: Broken Object Level AuthorizationAn endpoint takes an object id, but never checks that this caller may touch that object.
API2: Broken AuthenticationWeak or buggy login and token handling.
API3: Broken Object Property Level AuthorizationTwo old problems joined: excessive data exposure (returning fields the caller should not see) and mass assignment (accepting fields the caller should not set).
API4: Unrestricted Resource ConsumptionNo limits (lesson 5).
API5: Broken Function Level AuthorizationAdmin or high-power functions reachable by normal users because the check is missing or confused.
API6: Unrestricted Access to Sensitive Business FlowsAutomated abuse of a legitimate flow (lesson 5).
API7: Server Side Request ForgeryThe API fetches a URL given by the caller.
API8: Security MisconfigurationBad defaults, debug on, open CORS.
API9: Improper Inventory ManagementOld versions and forgotten hosts still running (lesson 4).
API10: Unsafe Consumption of APIsTrusting data from third-party APIs too much.

A bug of type API1 is simple. You wrote GET /orders/:id and it does SELECT * FROM orders WHERE id = ?. It never asks whose order it is. User A changes 101 to 102 and reads user B's order. OWASP says object-level checks should be considered in every function that uses an id from the user.

Let me show three of these bugs and their fixes in one small server, with real output. The "before" code:

JavaScript
// GET /orders/:id  -- BEFOREconst o = orders.get(m[1]);if (!o) return send(res, 404, { error: "not found" });return send(res, 200, o);                 // whoever asks gets it, with ALL fields// PATCH /me  -- BEFOREObject.assign(profile, JSON.parse(raw));  // copies every field the client sentreturn send(res, 200, profile);           // and returns every field, hash included

The "after" code:

JavaScript
// GET /orders/:id  -- AFTERconst o = orders.get(m[1]);if (!o) return send(res, 404, { error: "not found" });if (o.owner !== me.id && me.role !== "admin")  return send(res, 404, { error: "not found" });          // same answer as a missing orderconst { internalCost, ...publicView } = o;                // never show the cost to customersreturn send(res, 200, me.role === "admin" ? o : publicView);// PATCH /me  -- AFTERfor (const k of ["name"]) if (k in patch) p[k] = patch[k];  // allow-list of settable fieldsreturn send(res, 200, { id: p.id, name: p.name, role: p.role });  // allow-list of returned fields

Four calls, before and after. User A has token tokA and owns order 101. User B owns order 102.

TEXT
===== BEFORE the fixuser A reads own order 101           200 {"id":"101","owner":"u1","total":4200,"internalCost":1900,"note":"gift"}user A reads B's order 102           200 {"id":"102","owner":"u2","total":999,"internalCost":400,"note":"birthday"}no token                             401 {"error":"unauthenticated"}user A sets role=admin on self       200 {"id":"u1","name":"Ada","role":"admin","email":"ada@example.com","passwordHash":"x$abc"}===== AFTER the fixuser A reads own order 101           200 {"id":"101","owner":"u1","total":4200,"note":"gift"}user A reads B's order 102           404 {"error":"not found"}no token                             401 {"error":"unauthenticated"}user A sets role=admin on self       200 {"id":"u1","name":"Ada","role":"user"}

Before the fix, three different bugs show up in four lines:

  1. BOLA: user A read user B's order (200 with B's data).
  2. Excessive data exposure: even their own order shows internalCost, which is our margin. And the profile reply returned passwordHash.
  3. Mass assignment: a client that sent "role":"admin" in a profile update became an admin.

After the fix: B's order gives 404, the cost field is gone for normal users, and the role field in the PATCH is ignored (the reply still says "role":"user"). The fix for BOLA and property-level bugs is the same idea as the fix for SQL injection: an allow-list. List what is allowed, and refuse the rest.

Why 404 and not 403 for someone else's order? With 403, an attacker learns "order 102 exists, it's just not mine". With 404 they learn nothing, and cannot map out your ids. The cost: honest support staff may be confused. A common compromise is 404 for resources users could guess ids for, and 403 when the user knows the resource exists (they are a member of the project but lack the role). Pick one rule and document it.

Unguessable ids (UUIDs) do not fix BOLA. They make guessing harder, but ids leak in URLs, logs, emails and shared screenshots. The ownership check is the fix. Random ids are an extra layer, not a replacement. For a deeper walk-through of this bug see Security lesson 4.

Function-level authorization (API5)

Some endpoints are for admins only: DELETE /users/:id, POST /refunds. The classic mistake is to hide the button in the UI and forget the server check. Or the check exists on GET but not on DELETE, because the developer wrote the rules per method and missed one. The OWASP REST cheat sheet says to use an allow-list of HTTP methods per route, reply 405 for the rest, and make sure the caller may use that method on that resource. A tidy way to avoid gaps is to put the rules in one place (a table of route + method + required role/scope) and make the default deny.

Where to run the checks

The cheat sheet's advice for many small services is: authenticate in one place (an identity provider issues tokens), but make the access decision locally at each endpoint, to keep latency low and coupling small. Do not trust "the gateway already checked". A gateway can check that the token is valid and has the scope. Only the service that owns the data knows whether this user may touch this order.

Mistakes I see again and again

  • Credentials in the URL. ?api_key=... ends up in logs and in Referer. Use headers.
  • HTTP instead of HTTPS. The cheat sheet says secure REST services should only offer HTTPS endpoints. Anything else exposes keys and tokens.
  • No expiry. Keys that live forever and tokens that live for a year. Prefer short tokens and rotated keys.
  • One key for everything. A key with full access in every integration. Use scopes and separate keys.
  • Trusting the token body without verifying. Decoding a JWT is not verifying it.
  • No audience check. A token for one API accepted by another.
  • Putting the role in the request body, or trusting a header like X-User-Id set by the client.
  • Different error text for "no such user" and "wrong password". Gives attackers a free user list. (See Security lesson 3.)
  • Logging Authorization headers. Redact them.
  • Giving CORS * plus credentials. CORS is a browser rule, not an API security layer, but a bad setting can let other websites use a logged-in user's browser. See Browser lesson 3.

Try it

  1. Your GET /invoices/:id returns 200 for every id that exists. Write the one extra line that fixes BOLA, and say what status you return for another user's invoice.
  2. A client sends this to PATCH /users/me: {"name":"Sam","isAdmin":true,"balance":1000000}. The server does Object.assign(user, body). What happens, and what is the fix?
  3. A JWT has "alg":"HS256" in its header. Your code calls jwt.verify(token, secret) with no algorithms option and the library defaults. Why is it better to pass algorithms: ["HS256"] explicitly?
  4. A mobile app has an API key hardcoded to call your POST /orders. Why is that a problem, and what would you do instead?
  5. Which OAuth flow do you pick for (a) a React single-page app where users log in, (b) a nightly job on your server that syncs data, (c) a smart TV app?
  6. A user logs out, but their 15-minute JWT is still accepted for 10 more minutes. Name two ways to handle this.

Answers

  1. Add if (invoice.ownerId !== me.id) return 404. Return 404 (or at least a response that does not confirm the id exists) so attackers cannot learn which ids are real. Also keep the scope check, since the scope covers the action and the ownership check covers the object.
  2. All three fields are written to the user record. The caller becomes an admin and gets a million in balance. The fix is an allow-list of fields the user may set (for example only name), plus a validation schema that rejects unknown fields (lesson 2).
  3. If the library picks the algorithm from the token's header, an attacker can change the header to one you did not intend. Fixing the algorithm in your code removes that choice. Also note that the OWASP cheat sheet says the consumer must not let the token header select the verification algorithm.
  4. Anyone can extract the key from the app, so it is not a secret and the attacker can call the API as your app. Instead let the user log in (authorization code with PKCE), and have the app send a short-lived per-user token. Rate limit by user, and keep powerful actions behind server-side checks.
  5. (a) Authorization code with PKCE. (b) Client credentials, since the server acts as itself. (c) Device authorization, because typing a password on a TV is bad.
  6. Keep tokens short (5 minutes) and use refresh tokens that you can revoke. Or keep a denylist of revoked token ids (jti) that the API checks until each token's exp. For high-risk actions, ask the authorization server to check the token each time (introspection).

Interview questions

What is the difference between authentication and authorization? (Must know)

Authentication proves who the caller is: a password, a key or a token. Authorization decides what that caller may do. In an API, authentication usually happens once per request by checking the token, and authorization happens for each action and each object. I would add that most real API breaches are authorization bugs, such as one user reading another's data by changing an id.

When do you return 401 and when 403? (Must know)

I return 401 when the caller is not authenticated: missing, expired or invalid credentials. I include a WWW-Authenticate header. I return 403 when I know who they are and they are not allowed, such as a valid token without the needed scope. In my test, a token with only orders:read got 403 insufficient_scope on POST. For other users' objects, I often return 404 so the id's existence is not revealed.

How do you store API keys? (Must know)

I generate them from a secure random source, show the full value once, and store only a hash. Because the keys are long and random, SHA-256 is enough; I don't need a slow password hash. I add a readable prefix like sk_live_, a public id so I can look up the record, scopes, an owner and a "last used" time. I allow several keys per customer and support rotation. I compare hashes with a constant-time function.

What is in a JWT, and is it secret? (Must know)

A JWT has a header, a payload of claims such as sub, iss, aud and exp, and a signature. It is signed, not encrypted, so anyone with the token can read the payload, as I showed by decoding one without a key. The signature proves it was not modified. So I never put sensitive data in it.

How do you verify a JWT correctly? (Senior signal)

I verify the signature with an algorithm I choose in my own code, not one taken from the token header, and I reject alg: none. Then I check iss, aud and exp (and nbf if present). The audience check matters because a token meant for one service must not work on another. I use asymmetric signing (RS256 or ES256) when several services verify tokens, so only the issuer can create tokens. I keep access tokens short and use a denylist or short expiry to handle logout.

Explain the OAuth 2.0 authorization code flow with PKCE. (Must know)

The client makes a random code verifier and sends its SHA-256 hash, the code challenge, when it sends the user to the authorization server. The user logs in and approves. The server redirects back with a short-lived code and the state. The client checks state and then makes a direct request to the token endpoint with the code and the original verifier. The server hashes the verifier and compares it to the stored challenge. In my toy server, a stolen code without the verifier was rejected; without PKCE, the same stolen code gave a token.

Why was the implicit flow dropped, and what replaced it? (Senior signal)

The implicit flow returned the access token directly in the redirect URL fragment. That exposes tokens to browser history and to scripts on the page, and such tokens cannot be tied to the client. The OWASP OAuth cheat sheet, quoting the current security best practice, says to use the authorization code flow with PKCE for all client types, including single-page apps, and not to use the implicit grant. The same cheat sheet says not to use the password grant either.

What is BOLA and how do you prevent it? (Must know)

Broken Object Level Authorization, number one in the OWASP API Top 10 for 2023. The API takes an object id and returns the object without checking that the caller may access it. I prevent it by checking ownership or permission in every handler that uses an id from the client, ideally by scoping the query itself (WHERE id = ? AND owner_id = ?) so forgetting is hard. I test it with two users: user A must get 404 or 403 on user B's ids. Random ids are not a fix.

What is mass assignment, and how is it related to excessive data exposure? (Senior signal)

Mass assignment is copying all fields from the request body into a record, so a client can set fields like role or balance. Excessive data exposure is the mirror image: returning all fields of a record, such as a password hash or an internal cost. OWASP merged these as API3, Broken Object Property Level Authorization. The fix for both is an allow-list: a schema of fields the caller may set, and explicit response models of fields the caller may see.

Where should scopes be checked, and is a scope enough? (Trap)

Scopes are checked in the API on each endpoint. But a scope is not enough, because it describes what the app may do, not what the user may touch. A token with orders:read can still only read orders the user owns. I need both the scope check and the object-level check.

Why not put an API key in a mobile app? (Trap)

Anything in a shipped app can be extracted, so the key is public. The key only identifies the app, not the person. I would use per-user login with authorization code plus PKCE, short-lived tokens, per-user rate limits and server-side permission checks. If I must identify the app too, I treat that as a weak signal only.

Where do you do authorization in a system with many small services? (Senior signal)

Authentication can be central: an identity provider issues tokens. The access decision must be local, at the service that owns the data, because only it knows the rules for its objects. A gateway can reject bad tokens and missing scopes early, but it cannot know if user A owns order 102. I also keep the default deny, and I keep the rules in one table of route, method and required role.

Key takeaways

  • 401 means not authenticated, 403 means not allowed. Use 404 when you must hide that an object exists.
  • API keys identify applications. Store only hashes, show once, use a prefix, scopes and rotation. Never in URLs. Never in client-side code.
  • A JWT is signed, not secret. Verify the algorithm you chose, iss, aud and exp. Reject alg: none. Keep tokens short.
  • Use OAuth 2.0 authorization code with PKCE for user-facing apps, client credentials for machine-to-machine, and do not use implicit or password grants. The toy server showed PKCE blocking a stolen code and redirect_uri needing an exact match.
  • Scopes limit the app. Ownership checks limit the user. You need both.
  • The top API bugs are authorization bugs: BOLA, property-level (mass assignment and over-exposure) and function-level. The cure is allow-lists and a default of deny.
  • Don't write your own authorization server. Use a proven one.

What's next

You now have a well-designed, retry-safe and secured API. But nobody can use it if they cannot tell what it does. Next is Lesson 7: Documenting APIs with OpenAPI. We will write a real OpenAPI 3.1 file for the orders API (including the security scheme from this lesson), validate it with two real tools, build docs from it, run a contract test that catches a server that drifts from the spec, and start a mock server from it. Practise now: write down, for one API you use, the answer to this question for each endpoint: "which scope, and which ownership rule?"

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Part of the study guide

Related series