Home › High-Level Design › OAuth 2.0 and JWT
O

OAuth 2.0 and JWT

Almost every system design interview that touches "users" eventually hits auth, and most candidates fumble it because they blur two different jobs into one. OAuth 2.0 answers "how does a client get permission to act on a user's behalf?" JWT answers "what format is this token, and can I trust it without a database lookup?" They solve different problems, they show up together constantly, and getting the distinction clean is what makes you sound like someone who has shipped a login system instead of someone who skimmed a blog post last night.

High-Level DesignIntermediate11 min read

If you remember five things

The whole topic in one glance. Everything below defends these five calls.

  1. Two different jobs. OAuth is the protocol for getting and using tokens. JWT is one format for encoding them. You can run OAuth with opaque tokens, and you can use JWTs with no OAuth at all.
  2. Authorization Code flow with PKCE. The code comes back in the browser and is useless alone. It gets exchanged on a back channel, and PKCE closes the gap for mobile and single-page apps that cannot keep a secret.
  3. JWT is encoded, not encrypted. Anyone can base64-decode it and read the claims. The signature stops tampering, not reading. Never put a secret in the payload.
  4. Stateless versus stateful is the real trade-off. JWTs verify locally and scale beautifully, but you cannot un-issue them. Opaque tokens cost a lookup per request but revoke instantly.
  5. The hybrid wins. Short-lived JWT access tokens on the hot path, revocable refresh tokens for the session, and an optional tiny deny-list when you need faster kill.

1The core idea#

Start with the problem before you reach for a protocol. You have a user, a frontend or third-party app, and a backend API, and the API needs two facts on every request:

  • Who is this?
  • What are they allowed to do?

The naive answer, "send the username and password every time," is a disaster:

  • The password travels on every single call.
  • Every service that forwards the request now sees it.
  • You can never grant limited access without handing over the full keys.

OAuth 2.0 fixes this with indirection. Instead of passing credentials around, you pass a token that stands in for a specific grant of access.

  • The user authenticates once with an authorization server.
  • The server hands back a token scoped to certain permissions.
  • The client shows that token to the API.
  • The API trusts the token because it trusts the server that minted it.
  • Nobody downstream ever sees the password.

The mental model I give juniors: OAuth is the hotel front desk.

  • You show your ID and credit card once at check-in. That is authentication.
  • They give you a key card. That is the token.
  • The card opens your room and the gym, not the manager's office or someone else's room. Those are scopes.
  • Housekeeping holds a card that opens rooms but not the safe.
  • The card expires at checkout, and the maid never sees your passport.

JWT is a separate decision. It is just a format for a token.

  • A signed, self-contained blob carrying claims like "user id 4471, expires at 14:05, scope read:orders."
  • The point: the API verifies it with a public key and reads the claims directly, zero round trips to a database or auth server.
  • A token does not have to be a JWT. It could be a random 32-byte string that means nothing until you look it up. That is an opaque token.
The one distinction that earns the room

OAuth is the protocol for getting and using tokens. JWT is one popular way to encode them. You can run OAuth with opaque tokens. You can use JWTs completely outside OAuth. They pair well, but they are not the same thing, and candidates who treat them as one word get probed until they fall apart.

2How it works#

The flow you should know cold is the Authorization Code flow, behind essentially every "Log in with Google" button. Four parties: the user (resource owner), the client app, the authorization server, and the resource server (your API).

sequenceDiagram participant U as User participant C as Client App participant A as Auth Server participant R as Resource API U->>C: Click Log In C->>A: Redirect with client id and scope A->>U: Show consent screen U->>A: Approve A->>C: Redirect back with auth code C->>A: Exchange code plus secret A->>C: Return access token and refresh token C->>R: Call API with access token R->>R: Verify token signature R->>C: Return protected data
The Authorization Code flow. Notice the code travels through the browser, but it is traded for real tokens on a back channel that the browser never sees. That split is what keeps an intercepted redirect worthless.

Two things make this flow safe.

  • The authorization code that comes back in the redirect is short-lived and useless on its own. To turn it into a real token, the client exchanges it on a back channel using its client secret, which never touches the browser.
  • Modern public clients (mobile apps, single-page apps) add PKCE: the client generates a random secret, hashes it, sends the hash up front, and reveals the original only at exchange time. That stops an attacker who intercepts the code from redeeming it.
From the interviewer's chair

If you say "PKCE" unprompted when you mention a mobile or single-page client, I write it down. It tells me you know public clients cannot hold a secret, so the Authorization Code flow alone is not enough for them. That one acronym, used correctly, moves you from "read about OAuth" to "has shipped OAuth."

Now the token itself. If it is a JWT, it is three base64 parts joined by dots: header, payload, signature.

  • The header says which algorithm signed it, say RS256.
  • The payload holds the claims.
  • The signature is computed over the first two parts using the auth server's private key.
  • Your API holds the matching public key, so it checks the signature locally.
  • If it passes, the API knows the token came from the auth server and was not tampered with. It reads exp for expiry, aud to confirm the token was meant for this API, and iss to confirm who issued it.

A decoded JWT payload is just JSON:

{
  "sub": "4471",
  "iss": "https://auth.example.com",
  "aud": "orders-api",
  "scope": "read:orders write:orders",
  "iat": 1762425900,
  "exp": 1762426800
}
  • iat is issued-at, exp is expiry, both Unix seconds.
  • The gap is 900 seconds, a 15-minute access token.
  • The API trusts this blob only because the signature over the header and payload checks out against the public key.
The claim that fails candidates on the spot

Saying a JWT is "secure because it is encrypted." It is encoded, not encrypted. Anyone can base64-decode it and read every claim. Never put a password, a card number, or a secret in the payload. The signature protects against modification, not against reading. Get this wrong and the rest of your answer is suspect.

Short-lived access, long-lived refresh.

  • Access tokens are deliberately short, often 5 to 15 minutes.
  • When one expires, the client uses a long-lived refresh token to get a new access token without bothering the user.
  • Refresh tokens are sensitive: kept server-side or in secure storage, and revoked to kill a session.

3Trade-offs#

The headline decision is JWT (stateless) versus opaque tokens (stateful). This is the trade-off interviewers actually want you to reason about.

With a JWT, verification is local.

  • Your API validates the signature and reads the claims in microseconds, no network call.
  • That scales beautifully: 40 service instances at high load, none hitting a central auth database.
  • The cost is revocation. The token is self-contained and trusted until exp, so you cannot easily un-issue it. A stolen token with a 1-hour expiry works for up to an hour.

With an opaque token, the token is a meaningless reference.

  • Every API call looks it up in a shared store (Redis, a database) to resolve identity and scopes.
  • Revocation is trivial: delete the record and the next request fails instantly.
  • The cost is a lookup on every request, which means a dependency and latency on your hot path.
Pause before you read on: you pick opaque tokens at 50,000 requests per second across 40 instances. What is the hidden single point of failure you just signed up for?
Reveal the answer
The shared token store. Every one of those 50,000 requests per second now resolves through Redis, so that cluster is sized and replicated purely for auth, and you have added about 1 ms to the p50 of every request. Worse, if Redis hiccups, 100 percent of your traffic fails auth at once. You turned token lookup into your most critical dependency. Naming this before the interviewer does is the whole point.

Worked example: put real numbers on it#

Make the scale argument concrete, because this is where strong candidates pull ahead. Say you run 40 service instances at 50,000 requests per second.

Pick opaque tokens and every request pays for a lookup.

  • A Redis lookup inside the same region costs roughly 1 ms round trip.
  • That cluster now eats all 50,000 lookups per second. One node tops out around 100,000 simple GETs per second, so you size and replicate the cluster purely for auth, and you have added 1 ms to the p50 of every request.
  • If Redis ever hiccups, 100 percent of traffic fails auth. Auth just became your most critical single dependency.

Pick JWTs and each service verifies locally.

  • An RS256 signature check is around 50 to 100 microseconds of CPU and zero network.
  • At 50,000 requests per second that is about 2.5 to 5 CPU-seconds of work spread across 40 boxes, call it low single-digit percent of one core each.
  • No shared dependency, no added network latency, nothing to fall over.

The catch lands the moment a token leaks.

  • With a 15-minute JWT expiry, a stolen access token is usable for up to 900 seconds after you detect the breach, because there is nothing to delete.
  • With opaque tokens you delete one Redis key and the next request, 1 ms later, is rejected.
~900 s
The exposure window JWTs buyA stolen 15-minute access token keeps working for up to 900 seconds after you spot the breach, because a stateless token has nothing to delete. That bounded window is the price of a dependency-free hot path. Remember it: it is the sentence that captures the whole trade-off.

So the honest framing is one line: JWTs trade a guaranteed per-request cost for a bounded post-breach exposure window.

flowchart LR A[Client] --> B[API Gateway] B --> C[Order Service] C --> D[(Token Store)] C --> E[(User DB)]
Where the token store sits. With opaque tokens, every request through the Order Service touches the Token Store, so that box is on the hot path. With JWTs, the service verifies locally and the Token Store drops out of the per-request picture entirely.
Hot path default
JWT (stateless)
Verification
Local signature check, no network.
Latency cost
Near zero, about 50 to 100 us of CPU.
Revocation
Hard: wait for expiry.
Leaked token damage
Lives until expiry.
When revocation must be instant
Opaque (stateful)
Verification
Lookup in a shared store per request.
Latency cost
One cache or DB hit, about 1 ms.
Revocation
Instant: delete the record.
Leaked token damage
Killed on demand.
Dimension JWT (stateless) Opaque (stateful)
Verification Local signature check Lookup per request
Latency cost Near zero, ~50 to 100 us CPU One cache or DB hit, ~1 ms
Revocation Hard, wait for expiry Instant
Horizontal scale Excellent Needs shared store
Token size Larger, 500 bytes plus Small, 32 bytes
Leaked token damage Lives until expiry Killed on demand

The answer most senior engineers land on is a hybrid.

  • Use short-lived JWTs as access tokens, so the hot path stays fast and stateless.
  • Use stateful, revocable refresh tokens for the session.
  • When something goes wrong, revoke the refresh token, and within 15 minutes the access token expires and the attacker is locked out.
  • Need faster revocation? Keep a small deny-list of revoked JWT ids in Redis that services check.
  • The subtle win: that deny-list holds only the handful of tokens you have actively killed, so it stays tiny, maybe a few thousand entries, not one row per live session.

One more trade-off worth naming unprompted: the signing algorithm.

  • RS256 (asymmetric): the auth server signs with a private key, every service verifies with the public key, so services never hold a secret that can mint tokens.
  • HS256 (symmetric): one shared secret signs and verifies, simpler but every verifying service could also forge tokens.
  • In a multi-service setup, pick RS256. If one of your 40 boxes is compromised, an attacker with the HS256 secret can mint valid tokens for any user, while with RS256 they only get the public key, which verifies and cannot sign.

4Where it is used#

OAuth and JWT turn up in a few recognizable shapes. Name the pattern that fits the question in front of you.

Social login.

  • "Log in with Google, GitHub, Apple" is pure OAuth 2.0, layered with OpenID Connect (OAuth plus a standardized identity token).
  • You click the button, you never give Google your password through the third-party app, and the app gets a scoped token.

Internal microservices lean heavily on JWTs.

  • A request hits the API gateway, the gateway validates the token once, and the identity rides along in the JWT to every downstream service.
  • Each service verifies locally. No central auth bottleneck in the mesh.
  • This is the default pattern in most companies past a handful of services.

Machine-to-machine.

  • A backend job that calls another API uses the client credentials grant: no user involved, the service authenticates with its own id and secret and gets a token.

Clients and public APIs.

  • Mobile apps use the Authorization Code flow with PKCE.
  • Public APIs like Stripe or the GitHub API issue tokens scoped to specific permissions, so you can mint a key that reads repos but cannot delete them.

Reach for this whenever more than one party needs delegated, limited authority, or whenever you want to decouple the thing that proves identity from the things that consume it.

5Common pitfalls and gotchas#

Each of these is a real red flag I have watched fail candidates and break production systems.

  • Putting secrets in the JWT payload. It is readable. I have reviewed code that stashed internal role flags and a partial card number in the token. Assume the user sees every claim.
  • Not validating aud and iss. A token minted for service A should not be accepted by service B. Skip the audience check and a token fine for a low-privilege API can be replayed against a high-privilege one. Check issuer and audience every time, not just the signature.
  • The alg: none attack. Early JWT libraries honored a token whose header said the algorithm was "none" and skipped verification entirely. An attacker just strips the signature. Pin the expected algorithm server-side and reject anything else.
  • Long-lived access tokens. Setting a 30-day access token because refresh logic is annoying turns a leaked token into a 30-day breach. Keep access tokens in minutes, push the lifespan into the refresh token where you can revoke it.
  • Storing tokens in localStorage in the browser. Any XSS can read localStorage and steal the token. Prefer httpOnly cookies so JavaScript cannot touch them, and pair that with CSRF protection.
  • No refresh token rotation. On use, issue a new refresh token and invalidate the old. If an old one is ever used again, you know it was stolen and can kill the session. Without rotation, a leaked refresh token is a silent permanent backdoor.
  • Clock skew on exp. Drifting clocks reject valid tokens or accept expired ones. Allow a small leeway, say 30 to 60 seconds, and keep NTP running.

6How it shows up in interviews#

I rarely ask "explain OAuth." I drop it into a bigger design: "Users log in with Google, we have 12 microservices, how does auth flow through the system?" Here is what I am listening for.

  • Can you separate authentication from authorization? Weak answers use the words interchangeably. Strong answers say the auth server handles login and issues a token, and each service makes its own authorization decision from the token's scopes.
  • Do you reach for stateless versus stateful on purpose? Weak: "I will use JWTs" with no reasoning. Strong: "Short-lived JWTs so services verify locally and we do not bottleneck on an auth DB, plus revocable refresh tokens for logout and compromise." That tells me you have felt the pain of both.
  • Do you handle revocation? The follow-up I always ask is "a token gets stolen, now what?" The weak answer stalls. The strong answer already built for it: short expiry, revoke the refresh token, optionally a Redis deny-list for instant kill, and rotate refresh tokens so reuse is detectable.
  • Do you know the security basics? PKCE for public clients, RS256 over HS256 across services, httpOnly cookies, validating aud and iss, never trusting alg from the token. Naming two or three unprompted signals real experience.

Weak answer versus strong answer#

Same prompt, two candidates. Prompt: "Users log in with Google, we have 12 microservices. Walk me through auth, and tell me what happens when a token is stolen."

Weak candidate: I would use JWTs because they are secure and encrypted. The user logs in with Google, gets a JWT, and sends it on every request. Each service checks the JWT. If a token is stolen, well, the JWT is encrypted so they cannot really use it.

Interviewer: (writing: JWTs are encoded, not encrypted, so "cannot use it" is wrong. No reasoning for the choice. And no revocation story at all, which is the exact thing I asked about.)

Strong candidate: Google plus OpenID Connect handles login and issues an identity token. We exchange that for our own access token, a 15-minute RS256 JWT, so all 12 services verify locally with the public key and we never bottleneck on an auth database. The session is backed by a rotating refresh token in an httpOnly cookie, stored server-side so it is revocable. When a token is stolen, I revoke the refresh token immediately, so no new access tokens get minted, and the stolen access token dies on its own within 15 minutes. If 15 minutes is too long for our threat model, I add a Redis deny-list of revoked token ids, which stays tiny because it only holds actively killed tokens, and services check it on the hot path. Refresh rotation means if the old refresh token is ever replayed, I detect reuse and kill the whole session.

Interviewer: (writing: names the real trade-off, picks on purpose, breach plan already built in. Move to the next round.)

The weakest answer treats a JWT as a magic login that is safe because it is "encrypted." It is not encrypted and it is not magic. The strongest answers talk about it as a cache of identity you chose to trust for a few minutes, with a plan for the moment that trust goes wrong.

A JWT is a cache of identity you chose to trust for a few minutes. Design for the moment that trust goes wrong.

Follow-ups the interviewer will ask (and your answers)
"A token gets stolen. Now what?"
Short access-token expiry limits the blast radius. I revoke the refresh token immediately so no new access tokens get minted, and the stolen access token dies on its own within 15 minutes. If that window is too long, I add a Redis deny-list of revoked token ids that services check on the hot path, and it stays tiny because it only holds tokens I actively killed. Refresh rotation means a replayed old refresh token is detectable, so I kill the whole session.
"JWT or opaque tokens, and why?"
Hybrid. Short-lived JWTs as access tokens so 40 services verify locally in microseconds with no shared auth dependency, and stateful, revocable refresh tokens for the session. JWTs trade a guaranteed per-request lookup cost for a bounded post-breach exposure window, and the refresh token plus optional deny-list gives me the revocation story back.
"Why RS256 over HS256 across services?"
RS256 is asymmetric: the auth server signs with a private key, every service verifies with the public key, so no service holds a secret that can mint tokens. HS256 shares one secret for signing and verifying, so any compromised verifying box could forge tokens for any user. In a 12-service mesh, RS256 means a breached box only gets the public key, which cannot sign.
"Is a JWT encrypted? Can I put a role flag or card number in it?"
No. A JWT is base64-encoded and signed, not encrypted. Anyone can decode it and read every claim. The signature stops tampering, not reading. Never put a password, a card number, or any secret in the payload. If I truly need confidentiality on a claim, that is JWE, a different construct.
"What stops an attacker from stripping the signature or replaying a token at the wrong service?"
Two separate checks. For stripping, I pin the expected algorithm server-side and reject anything else, which kills the alg:none attack where a library trusts a "none" header. For replay across services, I validate aud and iss on every request, so a token minted for the orders API is rejected by the admin API even though the signature is valid.

What you can now explain in the room

  • Why OAuth (the protocol) and JWT (the token format) are different decisions that happen to pair well.
  • The Authorization Code flow end to end, and why PKCE matters for mobile and single-page clients.
  • Why a JWT is encoded, not encrypted, and what the signature actually protects.
  • The stateless-versus-stateful trade-off with real numbers: local signature checks versus a 1 ms lookup per request.
  • Why the hybrid (short JWT access tokens plus revocable refresh tokens plus an optional tiny deny-list) is the senior default.
  • How to answer "a token got stolen" with a plan already built in: short expiry, refresh revocation, rotation, deny-list.

Next, try the System Design Question walkthrough on designing a multi-service authentication gateway to see these trade-offs land in a full design.

Get a prep plan built around your OAuth 2.0 and JWT interview

Tell us the role and your interview date. We will send back a dated, day by day plan. Free.

Thanks. Check your inbox, your OAuth 2.0 and JWT prep plan is on the way.