Engineering Atlas › System Design Fundamentals

Who you are versus what you may do

15 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

Authentication: proving identity

A caller proves identity with one or more factors: something it knows (a password), has (a phone, a hardware key, a private key) or is (a fingerprint). Services prove identity with keys: an mTLS client certificate, a signed JWT from a workload identity system, or a cloud IAM role. Whatever the method, the output is a principal: a stable subject ID plus attributes such as tenant, roles and how strongly it was authenticated.

Password guidance changed recently, and many codebases still enforce the old rules. NIST's 2025 revision of SP 800-63B requires passwords used as the only factor to be at least 15 characters, and forbids both composition rules and forced periodic changes. Its highest assurance level, AAL3, requires a phishing-resistant authenticator, and syncable passkeys are allowed at AAL2 but not at AAL3 (NIST SP 800-63B-4).

Because proving identity is slow and sometimes interactive, systems do it once and issue a credential. A server-side session is a random ID in a cookie that maps to state in a store; revoking it is a delete. A self-contained token, usually a JWT, carries signed claims (sub, aud, exp, scopes) so any service can verify it without a lookup; revoking it before exp needs a denylist or short lifetimes. Most systems combine them: short-lived access tokens (5 to 15 minutes) plus a long-lived refresh token that is checked against a store.

Authorization: deciding access

Authorization needs four inputs: the principal, the action, the resource, and context (time, IP, device posture). Models differ in what they look at:

Two checks are always needed: a coarse one ("does this token carry the docs:write scope?") that a gateway can do, and an object-level one ("is this caller the owner of document 812, in the same tenant?") that only code with the data can do. OWASP ranks broken object level authorization as the top API security risk because the second check is so easy to forget, and calls it extremely common in API-based applications (OWASP API1:2023).

Getting the status codes right

RFC 9110 says a 401 response must include a WWW-Authenticate header naming how to authenticate, for example Bearer error="invalid_token" (RFC 9110 §15.5.2). A 403 says the server understood the identity and refuses. When the existence of a resource is itself sensitive, such as another tenant's invoice, return 404 so a caller cannot probe IDs to learn what exists. The same RFC explicitly allows this: a server that wants to hide a resource's existence may answer 404 instead of 403.

The problem shape

This is trust but verify: every hop decides how much to trust what the previous hop says. The same shape appears with AI agents. An agent acting for a user should authenticate as itself and be authorized with the intersection of its own permissions and the user's, never with a broad service account; otherwise it becomes a confused deputy that a prompt injection can steer toward data the user could never reach.

Down the stack

Layer 1The contract

Authentication promises: if verification succeeds, the claims were issued by the trusted issuer, for this audience, are not expired (within a stated clock skew, often 60 seconds), and have not been revoked as of the last revocation sync. Authorization promises: for a given principal, action, resource and policy version, the decision is deterministic, and it fails closed, so a missing policy or an error in the decision service means deny. The useful precision is about staleness: "a revoked token stops working within 60 seconds" and "a removed permission takes effect within 5 seconds" are testable promises; "immediately" usually is not.

Layer 2The mechanism

A JWT is three base64url parts: a header such as {"alg":"ES256","kid":"2024-07"}, a payload of claims, and a signature over the first two. Verifiers fetch the issuer's public keys from a JWKS endpoint, cache them by kid, and must pin the accepted algorithms: accepting alg: none, or letting an attacker switch RS256 to HS256 and sign with the public key as an HMAC secret, are classic bypasses (RFC 8725 §2.1). Then they check iss, aud, exp and nbf, and optionally a jti against a denylist. OAuth 2.0 bearer tokens are presented as Authorization: Bearer <token> (RFC 6750).

On the authorization side, the vocabulary is a policy enforcement point (PEP), the code that intercepts the request, and a policy decision point (PDP), the engine that answers allow or deny. Envoy's ext_authz filter is a PEP that calls a PDP over gRPC or HTTP and returns 403 on deny; errors from the PDP are let through only if failure_mode_allow is set to true, so fail-closed is a configuration choice you must keep (Envoy docs). Open Policy Agent, a CNCF graduated project, is a common PDP that evaluates Rego policies against a JSON input document and loaded data (OPA docs). Zanzibar stores relation tuples written as object#relation@user, such as doc:812#viewer@group:eng#member, and answers Check by walking the graph. To avoid the "new enemy" problem, where a check runs against data older than a recent permission removal, Zanzibar issues zookies, opaque consistency tokens that let a client ask for a snapshot at least as fresh as its last change.

Layer 3The machine

The costs differ by orders of magnitude, and that drives the architecture. A password check with bcrypt at cost 12 takes around 250 ms of CPU on purpose. OWASP now prefers Argon2id (at least 19 MiB of memory, 2 iterations), which also makes GPU guessing expensive in memory, and keeps bcrypt for legacy systems with a work factor of at least 10 and a 72-byte input limit that silently truncates longer passwords (OWASP Password Storage). Verifying an HMAC-SHA256 token signature takes about a microsecond; an ES256 verification is roughly 50 to 100 microseconds, and RSA-2048 verification about 20 to 30 microseconds because the public exponent is small. A session lookup in Redis is a 0.2 to 0.5 ms network round trip. A JWKS fetch on a cold cache is a TLS connection to the identity provider, tens of milliseconds, which is why keys are cached and refreshed in the background.

Authorization data is a memory and freshness problem. OPA keeps its data in process memory, so a check costs microseconds but every node must receive policy and data bundles. Zanzibar, per its paper, handles millions of checks per second across trillions of ACLs with a 95th-percentile latency under 10 ms and over 99.999% availability (Pang et al., USENIX ATC 2019), by caching heavily and using Spanner's timestamps to know which cached answers are still valid.

Why it's built this way

  1. Proving identity is slow and sometimes needs a human, so it happens once and produces a credential.
  2. Checking a credential happens on every request, so it must be local and cheap, which favours signed tokens with short lifetimes.
  3. Short lifetimes bound how long a stolen or revoked token works, trading a little refresh traffic for revocation without a lookup on every call.
  4. Permissions depend on the specific object, which only the owning service has, so object-level checks live in services, while coarse checks can live at the edge.
  5. Policies change and must be audited across many services, so decisions move into a shared engine with versioned policy, and enforcement stays local.

Diagram

flowchart TD
  R["Request with Bearer token"] --> A{"Token present?"}
  A -->|"no"| U1["401 missing_token"]
  A -->|"yes"| V{"Signature, aud, exp, revoked?"}
  V -->|"fails"| U2["401 invalid or expired"]
  V -->|"passes: principal"| S{"Scope docs:action?"}
  S -->|"no"| F1["403 insufficient_scope"]
  S -->|"yes"| T{"Resource in caller's tenant?"}
  T -->|"no"| N["404 not_found"]
  T -->|"yes"| O{"Owner or admin for writes?"}
  O -->|"no"| F2["403 not_owner"]
  O -->|"yes"| OK["200"]

Worked example

A document SaaS serves 12,000 requests per second. Users log in through the company's identity provider; login costs one redirect round trip and about 300 ms, and happens a few times a day per user. The identity provider issues ES256 access tokens valid for 10 minutes and refresh tokens valid for 30 days.

On the hot path the gateway verifies each token: 12,000 x 80 microseconds is about 1 CPU core across the fleet, with no shared store on the critical path. A session design would instead add a 0.3 ms Redis round trip, and a dependency whose outage logs everyone out, to all 12,000 requests per second. Revocation is the price. When an employee is fired, their refresh token is revoked at once, but the access token works for up to 10 more minutes. For admins the team cuts the lifetime to 2 minutes and pushes a jti denylist to gateways every 15 seconds.

Then a bug report: a user changed /docs/812 to /docs/813 in the URL and read another company's contract. The gateway had correctly checked the docs:read scope; the documents service had loaded document 813 by ID without comparing its tenant with the token's. The fix moves the tenant into every data access path, WHERE id = ? AND tenant_id = ?, returns 404 on mismatch, and adds a test that runs every endpoint with a token from a second tenant.

In the wild

Trade-offs

OptionStrengthsWeaknessesChoose when
Server-side sessionInstant revocation, small cookie, easy to reason aboutStore lookup on every request, shared dependencyWeb apps on one domain, strict revocation needs
Signed access token (JWT)Local verification, no shared store on the hot pathRevocation lags up to token lifetime; claims go staleMany services, high request rates
Opaque token plus introspectionRevocable, claims always freshIntrospection call per request unless cachedThird-party tokens, high-risk APIs
Authorization in each serviceUses local data, no extra hopLogic duplicated and hard to auditFew services, simple ownership rules
Central policy engine (OPA, Zanzibar-style)One policy language, auditable, consistentData distribution, another critical dependencyMany services, sharing, complex permissions

Pitfalls in production

Leadership lens

Check yourself

  1. A request carries a valid, unexpired token whose aud is billing-api, sent to docs-api. What should docs-api return, and why?

    Show answer

    401, because the token was not issued for this service; accepting it would let any service that receives a user's token replay it elsewhere.

  2. Why can a gateway enforce scopes but not reliably enforce "only the owner may edit"?

    Show answer

    Scopes are in the token, but ownership is a property of the stored object, which only the service that owns the data can load and compare.

  3. Access tokens live 10 minutes. A user's account is disabled. How long can they still act, and how would you shorten it?

    Show answer

    Up to 10 minutes on their current access token. Shorten the lifetime, push a denylist of token IDs to verifiers, or use introspection for sensitive operations.

  4. When is 404 a better answer than 403?

    Show answer

    When telling the caller that the resource exists is itself a leak, such as another tenant's records, so probing IDs reveals nothing.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →