Secure API access: SSO, passwords, and HTTPS
Security is an economics problem dressed up as a crypto problem. You can never make an attack impossible — you make it cost more than the prize. Every choice here is a deliberate move to bend the defender/attacker cost ratio in your favour.
0. The hinge
The hinge of this case is that three orthogonal concerns keep getting collapsed into one, and the collapse is what produces every famous breach:
- Identity — who is this caller? (authentication; SSO/OIDC delegates it.)
- Permission — what may they do to this resource? (authorization; SSO does not answer it.)
- Secret storage & transport — passwords at rest (slow KDF), bytes in flight (TLS).
Keep them separate and each gets a clean, well-understood mechanism. Blur them — "the gateway validated the JWT, so we're authorized" — and you ship an authorization-bypass bug. The senior move is to name the three axes out loud and assign each its own primitive.
1. Clarify the contract
Treat the prompt as a product contract before a box diagram. The system must:
- Authenticate users (interactive login, possibly via a corporate IdP) and services (machine-to-machine).
- Authorize actions per resource — deny by default, check close to the data.
- Protect credentials at rest (never recoverable) and in transit (TLS everywhere).
- Support SSO / federated login so users don't get a new password per app.
- Audit privileged access with immutable events.
And explicitly what it need not do: it need not invent crypto (use TLS, use argon2/bcrypt, use a vetted OIDC library); it need not store the user's password at the IdP-delegated apps at all (that's the point of SSO); it need not be its own certificate authority. Reinventing any of these is how juniors lose the interview.
2. Put numbers on it — the cost asymmetry
The load-bearing number in all of security is the password-hash cost factor. A modern KDF (bcrypt, scrypt, argon2id) has a tunable work parameter you set so that one hash takes roughly 100 ms on your server hardware. That is imperceptible to a human logging in once, but it is a wall the attacker slams into a trillion times. The whole defence is in this table — read it as "how many guesses per core does my choice of cost factor hand the attacker?"
| Hash choice | Time per hash | Attacker guesses/s/core | Verdict |
|---|---|---|---|
| MD5 / SHA-256 (fast, unsalted) | ~5 ns | ~108–1010 (GPU) | Catastrophic. A leaked DB is cracked in hours. |
| bcrypt cost 10 | ~60 ms | ~16 | OK; the 2015-era default. |
| bcrypt cost 11 / argon2id (tuned ~100 ms) | ~100 ms | ~10 | Target. Online guessing is hopeless; offline is brutal. |
| argon2id, high mem (~250 ms) | ~250 ms | ~4 | Stronger; cap so login p99 stays acceptable. |
The arithmetic that matters: at 100 ms/hash, one core does 1000 / 100 = 10 guesses/s. Online guessing through your login endpoint is therefore capped at ~10/s/core even before a rate limiter — and a rate limiter (see foundation lesson 15) takes it to single digits per minute. Offline, if the hash DB leaks, the attacker has all the cores they can rent — but against a strong per-user salt they must crack each hash independently, so a 12-character random password at 10 guesses/s/core against a 95-char alphabet is 9512 / 10 ≈ 5.4×1022 core-seconds. That is the point: you converted "the DB leaked, game over" into "the DB leaked, and weak passwords fall while strong ones survive long enough to force a rotation."
The second load-bearing number is the token TTL, because it sets the revocation-lag window: the time a stolen token stays valid after you've decided to kill it.
| Access-token TTL | Refresh load | Worst-case window a stolen token is usable |
|---|---|---|
| 5 min | 1 refresh / 5 min / session | ≤ 5 min (then refresh fails against a revoked refresh token) |
| 1 hour | 1 refresh / hour | ≤ 60 min |
| 24 hours | negligible | up to 24 h — a full day of attacker access after you "revoked" |
A stateless JWT cannot be un-issued; the verifier just checks the signature and expiry. So the only lever a pure-JWT system has on a stolen token is its TTL. A 5-minute access token bounds the damage to 5 minutes at the cost of one refresh round trip every 5 minutes — cheap. A 24-hour token saves the refresh traffic and hands the attacker a full day. Short TTL + refresh rotation is the standard answer, and the deep dive below makes the revocation tension precise.
3. Data model & surface area
Keep the API small and the ownership crisp; vague data ownership makes every later box vague too.
| Endpoint | Why it exists |
|---|---|
POST /authorize (OIDC) | Start the authorization-code flow; redirect to the IdP. |
POST /token | Exchange the auth code for access + refresh tokens (back channel). |
POST /token/refresh | Rotate a short-lived access token using the refresh token. |
POST /logout / revoke | Invalidate the refresh token / bump the user's token version. |
Note what's stored server-side and what isn't: the access token is stateless (held only by the client), but the refresh token and a per-user token_version integer live in your DB — that single column is the entire escape hatch for revoking stateless JWTs, as the deep dive shows.
4. Linearized design — follow a login through the system
Walk the OAuth/OIDC authorization-code flow in the order bytes actually move. This is the dominant pattern for delegated login and the diagram every interviewer wants on the board.
- 1. TLS terminates at the edge/gateway. The client-to-edge path is encrypted; the resource servers behind it talk over a separately-secured internal hop (mTLS or signed identity headers — see the TLS deep dive). The API never sees raw bytes off the public wire.
- 2–4. The app redirects the browser to the Authorization Server (IdP). The user authenticates there — the app never handles the password. The IdP redirects back with a short-lived, single-use authorization code on the front channel (the browser).
- 5. The app exchanges the code for tokens on the back channel (server-to-server, with the client secret). Codes go front-channel because they're useless without the secret; tokens stay back-channel because they're bearer credentials.
- 6–7. The app calls the resource server with
Authorization: Bearer. The API verifies the JWT signature + expiry statelessly, then — the step juniors skip — authorizes the specific action on the specific resource, denying by default. Identity ≠ permission.
5. Deep dives
(1) Session cookie vs JWT — the revocation tension
This is the genuine fork, and there is no free lunch. A server-side session stores session state (a row keyed by an opaque cookie) in your DB/cache. To revoke, you delete the row; the next request finds nothing and is rejected immediately. The cost: every request does a session lookup — a stateful dependency, a hot store, a thing to replicate.
A JWT is stateless: the signature is the proof, so any verifier validates it with the public key and zero shared state. That's the scaling win — no session store, no per-request DB hit, trivially horizontal. The cost is the dark mirror of the win: you cannot un-issue it. Once signed, it's valid until it expires, full stop. You traded a stateful dependency for an inability to revoke.
Three honest ways to claw back revocation on a JWT, none free:
- Short TTL + refresh rotation. Access token lives 5 min; the long-lived refresh token is stored server-side. Revoke by deleting the refresh token — the next refresh (≤ 5 min away) fails. Bounds the window to the access TTL while keeping per-request verification stateless. The standard answer.
- Token version / generation counter. Embed a
token_versionin the JWT and keep the current version per user in a small, cached store. Bump it to invalidate all of a user's tokens at once (the "log out everywhere" / "I was breached" button). You've reintroduced one cheap lookup — but a cacheable one, not a full session. - Revocation list (blocklist). Maintain a set of revoked token IDs (jti) until their TTL lapses; verifiers check membership. Smallest possible state, but it's a per-request lookup again — the very thing JWT was meant to avoid.
This is where DDIA Ch. 9 (linearizability) sharpens the discussion. The moment you add a revocation list or token-version store, "is this token revoked?" becomes a read that races the revoke write. If the store is eventually consistent or cached, a verifier can do a stale read — see the old (still-valid) version and admit a token you just killed. The revocation window then isn't just the TTL; it's TTL plus the replication/cache lag. Making revocation truly instant requires a linearizable read of the version store on the auth path — which costs you exactly the per-request coordination JWT was chosen to avoid. Name that trade-off and you've answered the senior question before it's asked: you accept a bounded-staleness window, sized to your TTL, rather than pay for linearizable reads on every call.
(2) Password hashing — salt + slow KDF, and why fast hashes lose
Two independent jobs, often confused. The salt (a unique random value per password, stored alongside the hash) defeats precomputation: without it, an attacker hashes the top million passwords once into a rainbow table and instantly matches every user who picked one of them, and two users with the same password get the same hash (a visible tell). A unique salt means the attacker must redo the work for every user — no precomputation, no shared progress.
The slow KDF defeats throughput. A general-purpose hash (SHA-256) is designed to be fast — that's a virtue for checksums and a catastrophe for passwords, because the attacker inherits the speed: ~1010 guesses/s on a GPU. bcrypt/scrypt/argon2 are deliberately slow and, crucially, memory-hard — argon2 forces each guess to touch a large memory buffer, defeating the GPU/ASIC parallelism that makes fast hashes cheap to brute. You tune the cost factor (per the §2 table) so one hash ≈ 100 ms on your hardware. The asymmetry: your user waits 100 ms once; the attacker pays 100 ms × (every guess) × (every user, thanks to the salt). And because the cost factor is stored with each hash, you can ratchet it up over time and re-hash on next login (the "migrate old hashes on login" pattern), keeping pace with hardware.
(3) TLS termination at the edge
TLS protects the client-to-edge path: confidentiality (no eavesdropping), integrity (no tampering), and server authentication (you're really talking to the right host). Terminating it once at the edge/gateway centralizes certificate management — one place to rotate certs, enforce TLS 1.3, pin ciphers — and lets backends speak cheaper plaintext or a lighter internal protocol. The catch is that termination creates an internal trust boundary: everything behind the edge is now implicitly "trusted," which is exactly the assumption attackers exploit after a perimeter breach. So the edge is not the end of the security story — internal hops still need an explicit trust model: mTLS (each service proves identity with a cert) or signed identity headers the edge stamps and services verify. The principle (echoing lesson 15's gateway): terminate and authenticate once at the edge, then propagate a trusted, signed identity inward — never a blindly-trusted one.
6. Trade-offs
| Choice | Buys | Costs | Choose when |
|---|---|---|---|
| Session cookie vs JWT | immediate server-side revocation | stateful session-store dependency, per-request lookup | first-party web apps where you control the store and want instant logout |
| Long token TTL vs short TTL | (long) less refresh load | (long) longer post-revoke compromise window | short + refresh rotation by default; long only for low-risk, low-value sessions |
| Central authz vs service authz | one consistent policy, easy audit | extra dependency / hop, a policy SPOF | central for coarse policy; service-side for resource-level (deny-by-default) checks |
| API keys vs OAuth/OIDC | (keys) dead-simple server-to-server | (keys) no delegated user consent, coarse scoping, manual rotation | keys for machine-to-machine; OAuth/OIDC whenever a human delegates access |
The sharpest one is session vs JWT, and the honest framing is: you are choosing where to pay for revocation. Sessions pay per-request (a lookup on every call) to get revocation for free. JWTs pay nothing per-request but pay in a revocation-lag window (the TTL) — and any attempt to shrink that window drags a lookup back onto the hot path. There is no design that is both stateless and instantly-revocable; pick the cost you can afford and size the TTL to your risk.
7. Failure modes
| Failure | Mitigation |
|---|---|
| Credential stuffing (leaked passwords replayed) | Slow KDF caps online guessing; rate-limit the login endpoint hard (lesson 15); monitor for credential-stuffing patterns; step up to MFA on risk signals. |
| Stolen JWT used until TTL | Short access TTL + refresh rotation; revocation list or token_version bump for "kill everything now"; accept the bounded stale-read window. |
| Password hash too weak | Tune cost factor to ~100 ms; store the cost with the hash and re-hash on next login to ratchet up; never a fast/unsalted hash. |
| Authorization bypass (IDOR) | Resource-level checks close to the data, deny-by-default; never infer permission from a valid token alone. |
| Internal hop trusted blindly after edge breach | mTLS or signed identity headers between services; don't treat "behind the edge" as authenticated. |
8. Interview Q&A
- A JWT is stolen. Revoke it before its TTL expires — how? (senior answer) You fundamentally can't un-sign a stateless token, so attack the window three ways: keep access TTLs short (5 min) so the damage is self-limiting; back them with refresh-token rotation and delete the refresh token server-side to stop renewal; and for "kill it now" keep a
token_versionper user (or a jti blocklist) that verifiers check, bumping it to invalidate everything. Then name the cost honestly — that check reintroduces a per-request lookup, and if it's a stale (non-linearizable) read the token survives the cache lag on top of the TTL (DDIA Ch. 9). The principled position is to accept a bounded window sized to the TTL rather than pay for linearizable reads on every call. - Why is a slow password hash a feature, not a bug? (senior answer) Security is a cost-asymmetry game. Tuning the KDF to ~100 ms/hash is invisible to a user logging in once but caps online guessing at ~10/s/core and makes offline cracking pay 100 ms per guess per user. A fast hash hands the attacker their own speed — ~1010 GPU guesses/s — and a leaked DB falls in hours. Memory-hardness (argon2) further kills GPU/ASIC parallelism.
- What does the salt do that the slow hash doesn't? (senior answer) Different jobs. The salt defeats precomputation: unique per user, it kills rainbow tables and means identical passwords don't collide, so the attacker can't share work across users. The slow KDF defeats throughput: it caps guesses/sec. You need both — a salted MD5 is still cracked at GPU speed; a slow hash with a global salt still allows one precomputed table.
- Walk the OAuth authorization-code flow and why the code goes front-channel but tokens don't. (senior answer) Browser → app → redirect to IdP → user authenticates at the IdP → IdP redirects back with a short-lived single-use code → app exchanges code for tokens on the back channel with its client secret. The code rides the front channel (browser) because it's useless without the secret; the access/refresh tokens are bearer credentials and never touch the front channel. The app never sees the password — that's the SSO win.
- Session cookie vs JWT — when each? (senior answer) Sessions for first-party apps where you want instant revocation and can afford a per-request lookup against a store you control. JWTs for stateless, horizontally-scaled, multi-service or third-party verification where you can't (or won't) share session state — accepting that revocation costs you a TTL-sized window. It's a question of where you pay for revocation, not which is "more secure."
- Why isn't a valid token enough to authorize a request? (senior answer) A token proves identity and scope, not permission to touch a specific object. Resource-level authorization must run at the resource server, deny-by-default, checking can-this-user-act-on-this-row. Skipping it is the broken-object-level-authorization / IDOR class of bug — the most common API vuln in the wild.
- You terminate TLS at the edge — is the internal network now safe? (senior answer) No. Termination centralizes cert management but creates an implicit trust boundary that a perimeter breach turns into free lateral movement. Internal hops need explicit identity — mTLS or edge-signed, service-verified identity headers. Authenticate once at the edge, then propagate a signed identity inward, never a blindly-trusted one.
- How long should an access token live? (senior answer) As short as the refresh load tolerates — typically 5 min, because the TTL is your worst-case post-revocation exposure for a stateless token. 24h saves refresh traffic but hands an attacker a full day after you've "revoked." Pair short TTLs with refresh rotation so the user never notices.
Related lessons
This case leans hardest on foundation lesson 15 (rate limiting & the edge): the login endpoint is the canonical thing to rate-limit, and the slow-hash cap (~10 guesses/s/core) combines with token-bucket admission control to turn credential stuffing from a flood into a trickle — the same gateway that does TLS termination here is lesson 15's front door. Lesson 13 (fault tolerance) governs the auth path's degraded modes: if the session/revocation store is down, you must choose fail-open (stay up, lose protection) or fail-closed (protect, cause an outage) deliberately. Lesson 03 (API design) frames the small, intentional surface — /authorize, /token, /refresh, /logout — and the deny-by-default contract. The revocation stale-read tension is consistency/CAP territory (DDIA Ch. 9 linearizability).