Security7 min · 50 of 64

AuthN & AuthZ

Separate identity from permission, price a token's revocation window against a session lookup, and put the ownership check where the data lives.

Authentication asks who you are. Authorization asks what you may do. They fail differently, and the second is the one designs forget: a service that verifies a token perfectly, then returns whatever row the URL names, has authenticated an attacker and handed over someone else's data.

Authentication happens once, at login. Authorization happens on every request, and the user id in the WHERE clause is what candidates forget.
Login issues a signed token; every API call verifies it locally and then checks permissionPOST /login · email+ passwordauthentication: hash compare ·MFAJWT · signed,expires in 15 minGET /orders/9 · Bearer JWTverify signature with the publickey · no call to authauthorization: does user 42 ownorder 9?SELECT … WHERE id =9 AND user_id = 42row200 · or 403 if the row is someone else'sBrowserAuth serviceOrders APIOrders DB

Scroll to zoom · drag to pan · 0 fits · Esc closes

Authentication — proving identity

Authentication verifies a caller is who they claim to be. Factors come in three families: something you know (a password), something you have (a TOTP app or WebAuthn key), something you are (a fingerprint).

Multi-factor authentication (MFA) combines factors from different families: two passwords are not MFA, a password plus a TOTP code is, because stealing a credential database and stealing a phone are unrelated attacks.

Do not invent the login flow. OAuth 2.0 grants an application limited access to a user's resources without handing over a password: delegated authorization. OpenID Connect (OIDC) is the identity layer on top and the part that authenticates, returning an ID token. "Log in with Google" is OIDC on OAuth 2.0.

The session decision: signed token or server-side session

A JWT is a signed, self-contained token: header, claims (sub, exp, roles), signature. Every service verifies it with the issuer's public key, locally, in microseconds. A server-side session keeps the state and hands back an opaque 32-byte id, so every request costs a shared-store lookup, roughly 0.5 ms inside a datacenter.

The usual argument for tokens is speed, and it is the weakest one. Assume 10 million DAU at 25 requests each: 250 M ÷ 86,400 s ≈ 2,900 QPS, call it 3,000, the load Check yourself Q2 reuses. Against a 50 ms budget a 0.5 ms lookup is 1%, and Little's Law puts 3,000 QPS × 0.0005 s = 1.5 lookups in flight, so one Redis node is idle. Tokens instead remove a dependency: the store can be down and requests still succeed, the trade covered under stateful vs stateless services.

What tokens cost is revocation. A signed token works until it expires, so firing an employee or changing a password leaves the old one valid for up to its 15-minute TTL. Shortening that moves the cost rather than removing it: a 1-minute token across 100,000 concurrent sessions costs 100,000 refreshes a minute, 1,700 QPS of pure token minting, putting the auth service on the critical path as another tier to scale.

Production lands in the middle: short-lived access tokens, a revocable refresh token server-side, and a denylist of revoked ids that need only outlive an access token.

concurrent sessions     100,000   assume ~30 min each, so a day turns over
                                  100,000 × (1,440 ÷ 30) ≈ 4.8 M sessions
revocations/day         200,000   ≈4% of those — logouts, password changes,
                                  admin actions
live window             15 min of 1,440 min/day
live entries            200,000 × 15 ÷ 1,440 ≈ 2,100
size at ~50 B per id    2,100 × 50 B ≈ 105 KB

105 KB fits in every app server's process memory, so the check is a ~100 ns main-memory reference rather than a network hop, fed by pub/sub from the auth service. Its own cost: a node that misses a push keeps honouring a revoked token, so the path needs monitoring and periodic resync.

A JWT is not private. The payload is base64-encoded, not encrypted, so anyone holding it reads every claim; confidentiality on the wire is encryption's job.

Authorization — deciding what an identity may do

Authorization runs after authentication, on every request. Three models, in rising order of expressiveness and cost:

  • Role-Based Access Control (RBAC) attaches permissions to roles (admin, editor, viewer) and roles to users: N users against M permissions becomes N assignments, not N×M grants.
  • Access Control Lists (ACLs) attach a permission list to each resource, the shape for per-object sharing, at the cost of a list that grows with the resources.
  • Attribute-Based Access Control (ABAC) evaluates policy over attributes of user, resource, action and environment: "an EU adjuster may read a claim from their own region during business hours." It costs a policy engine on the request path, and denials nobody can explain unless the deciding rule is logged.

Start with RBAC; move to ABAC only when a requirement cannot be written as a role. That ordering is the judgment being graded.

The two failures that actually happen

Both are common threats top-ten entries: OWASP Top 10 2021 A07 Identification and Authentication Failures, and API Security Top 10 API1 broken object-level authorization and API2 broken authentication.

The first is a token the server accepts that the issuer never signed, and it needs no cryptanalysis. Every instance is a verification bug: a library that honours alg: none and skips the check; algorithm confusion, where an RS256 verifier is handed an HS256 token and HMACs it with the public key it publishes; an HMAC secret short enough to brute-force offline; a forged kid header steering the verifier at a key the attacker controls. The control is configuration, not cryptography: pin the expected algorithm and key, never read alg from the token, and give the secret real entropy.

The second is more frequent, a request that authenticates fine and is never authorized:

-- the bug: authenticated user 42 fetches order 9, which belongs to user 77
SELECT * FROM orders WHERE id = 9;

-- the fix: ownership is a predicate in the query, not a separate step
SELECT * FROM orders WHERE id = 9 AND user_id = 42;

The fix picks the status code. With the owner id in the WHERE clause the query matches zero rows, so the service cannot tell "not yours" from "does not exist" and answers 404 — a 403 would confirm order 9 exists. Sequential ids are cheap to walk at whatever the rate limiter allows, 1,000 a minute being 1.44 million rows a day; random UUIDs raise the cost of guessing but are obscurity, not access control.

Where each check belongs

Authenticate at the edge, authorize next to the data. An API gateway verifies the signature, rejects expired tokens and enforces coarse role rules once instead of per service. It cannot do the rest: whether user 42 may read order 9 needs the orders table, and a gateway querying each service's data has become a distributed join on the hot path, one fan-out per request and an edge redeploy whenever a team changes a rule.

The consequence to say out loud: services behind the edge trust a forwarded identity header, so they must not be directly reachable, or anything inside the network can set it and become any user. The security model is then network placement, the assumption that breaks first during a migration; mutual TLS makes the trust real.

In an interview

Cover both checks in one pass: "Login goes through OIDC on OAuth 2.0, so we never store passwords. It issues a 15-minute access token plus a revocable refresh token. The gateway verifies the signature with the algorithm pinned and checks the role; the orders service does the ownership check inside the query, because it alone knows who owns an order. RBAC to start, ABAC only if a rule needs resource or time attributes." Under thirty seconds covering identity, session, placement and model. Expect the revocation follow-up, and answer with the bound: fifteen minutes of stale access, cut to near-zero by 2,100 denylist entries and 105 KB per node.

The mistake that loses most points is stopping at authentication, treating a valid token as permission and never naming the per-request ownership check. Second: fine-grained authorization in the gateway, which sounds centralised and is a coupling problem. Third: "we use OAuth to log users in", with no mention of OIDC.

Check yourself

1. First requirement: "anyone with the support role can read any ticket." A year later: "support agents may read only tickets from their own country, during their shift." Which model each time, and what did the second rule break?

The first is RBAC and a single role check. The second is not expressible as a role without inventing support-IN-dayshift roles, multiplying roles by country by shift, which is how organisations end up with thousands. That is the ABAC trigger: policy over user attributes (country, shift), resource attributes (ticket country) and environment (time). State the price unasked: a policy engine on the request path, and denials that need the deciding rule logged.

2. Your API serves 3,000 QPS and someone proposes putting all 200 of a user's fine-grained permissions into the JWT to avoid a lookup. Do the arithmetic and decide.

200 permission strings at ~30 bytes is ~6 KB of claims, ~8 KB base64-encoded, at or past the 8 KB header buffer nginx allows by default (large_client_header_buffers 4 8k). Know the log line: nginx, Apache, HAProxy and Cloudflare answer 400 Bad Request ("Request Header Or Cookie Too Large"), while 431 comes from Node's HTTP server and a minority of stacks. Only users with many permissions break, and no fresh test account reproduces it. Bandwidth is the smaller problem: 3,000 × 8 KB = 24 MB/s inbound, sustained, to avoid a 0.5 ms lookup worth 1% of a 50 ms budget. Keep short role strings and resolve permissions server-side, cached per role; the proposal fits only a small, stable permission set.