System Design · Security Deep Dive

OAuth 2.0, OpenID Connect & JWT Explained for Backend Engineers

What each spec actually does, how the authorization code + PKCE flow works message by message, how to verify a JWT correctly, and the mistakes that get APIs breached.

✍️ Pranjal Jain, Ex-Microsoft · IIT Kanpur 📅 10 October 2026 ⏱ 19 min read
Illustration of a key passing through layered security gates toward a server, with a three-part sealed token beside it
TL;DR
  • OAuth 2.0 is delegated authorization (get an access token to call an API). OpenID Connect adds authentication (an ID token saying who logged in). JWT is just a token format that both often use.
  • For almost every client today, use the authorization code flow with PKCE. Use client credentials for service-to-service. Implicit and password grants are deprecated.
  • A JWT is base64url(header).base64url(payload).signature. It is signed, not encrypted: anyone can read it. Verify signature, alg allowlist, iss, aud, exp on every request.
  • Short-lived access tokens (5 to 15 min) plus rotating refresh tokens with reuse detection is the standard way to get statelessness and revocability together.
  • Most real breaches come from boring mistakes: accepting alg: none, not checking aud, open redirect URIs, tokens in localStorage, and long-lived tokens with no revocation path.

Most backend engineers use OAuth daily through a library and a "Sign in with Google" button, and then freeze in an interview when asked "why does the authorization code flow exist at all, if the server could just return a token?" This guide builds the model from the ground up: which spec solves which problem, what travels over the wire in each step, how a JWT is actually verified, and where implementations go wrong. Everything here maps to the current specs: RFC 6749 (OAuth 2.0), RFC 7636 (PKCE), RFC 7519 (JWT), RFC 9700 (OAuth Security Best Current Practice) and OpenID Connect Core 1.0.

OAuth 2.0 vs OpenID Connect vs JWT: Three Different Things

The single most common confusion is treating these as alternatives. They sit at different layers and usually appear together.

What it isQuestion it answersKey artifact
OAuth 2.0Authorization framework (RFC 6749)"May this app call this API on the user's behalf, with which permissions?"Access token, refresh token, scopes
OpenID Connect (OIDC)Identity layer on top of OAuth 2.0"Who is the user who just logged in, and when/how did they authenticate?"ID token (always a JWT), /userinfo, discovery document
JWTToken format (RFC 7519) plus JWS/JWE for signing/encryption"How do I pack claims into a compact, verifiable string?"header.payload.signature

An OAuth access token does not have to be a JWT. Many providers issue opaque tokens (random strings) that the API validates by calling an introspection endpoint (RFC 7662). Conversely, you can use JWTs with no OAuth at all, for example a monolith that issues its own session JWT after password login. When an interviewer says "we use JWT for auth", the right first question is: "issued by whom, verified by whom, and is there an OAuth/OIDC provider involved?"

💡
One-line mental modelOAuth hands out keys to rooms (access tokens with scopes). OIDC hands out an ID card (ID token). JWT is the material both the key and the card can be printed on.

The Four OAuth Roles (and the Problem OAuth Solves)

Before OAuth, if a photo-printing site wanted your Google Photos, it asked for your Google password. That gave it full, permanent, unrevocable access to everything. OAuth's job is to let the user grant limited, revocable, scoped access without sharing credentials. It defines four roles:

  • Resource owner: the user who owns the data.
  • Client: the application that wants access (the printing site, your mobile app, your SPA, a cron job).
  • Authorization server (AS): authenticates the user, shows the consent screen and issues tokens. Examples: Google, Auth0, Okta, Keycloak, AWS Cognito, or your own.
  • Resource server (RS): the API holding the data. It accepts access tokens and enforces scopes.

Clients come in two kinds. A confidential client runs on a server you control and can keep a client_secret. A public client (SPA, mobile app, desktop app, CLI) ships its code to users, so any embedded secret is extractable in minutes with a decompiler or browser devtools. This distinction drives which grant type you use and why PKCE exists.

The Authorization Code Flow with PKCE, Step by Step

This is the flow behind "Sign in with Google" and the one you should be able to draw on a whiteboard. Suppose a food-ordering app (client) wants to read the user's profile and calendar from an identity provider.

User/Browser Client app Auth Server API (RS) │ │ │ │ │ 1. click "Login" │ │ │ │────────────────────►│ gen code_verifier, │ │ │ │ code_challenge= │ │ │ │ SHA256(verifier) │ │ │ 2. 302 redirect to /authorize?...&code_challenge=.. │ │◄────────────────────│ │ │ │ 3. GET /authorize ───────────────────────►│ │ │ 4. login + consent screen ◄──────────────►│ │ │ 5. 302 to redirect_uri?code=AUTHCODE&state=xyz │ │◄──────────────────────────────────────────│ │ │ 6. GET /callback?code=..&state=.. │ │ │────────────────────►│ │ │ │ │ 7. POST /token │ │ │ │ code + verifier ──►│ check SHA256( │ │ │ │ verifier)==chall. │ │ │ 8. access_token, │ │ │ │◄── refresh, id_token│ │ │ │ 9. GET /api Bearer access_token ──────►│ │ │◄──────────────────────────── 10. data ──│
  1. Generate PKCE values. The client creates a high-entropy random code_verifier (43 to 128 characters) and computes code_challenge = BASE64URL(SHA256(code_verifier)). It also creates a random state (CSRF protection) and, for OIDC, a nonce. It stores these in the session.
  2. Redirect to the authorize endpoint. The browser goes to the AS with response_type=code, client_id, redirect_uri, scope, state, code_challenge and code_challenge_method=S256.
  3. User authenticates and consents at the AS. The client never sees the password.
  4. AS redirects back with a short-lived code. The code is single-use and typically valid for under a minute. It travels through the browser's URL, so it can leak via logs, history or a malicious app registered for the same custom URL scheme.
  5. Client validates state against what it stored. Mismatch means a forged callback; abort.
  6. Back-channel token exchange. The client POSTs the code, redirect_uri and the original code_verifier (plus client_secret if confidential) directly to the AS over TLS. The AS hashes the verifier and checks it equals the stored challenge.
  7. AS returns tokens: access_token, expires_in, usually refresh_token, and id_token if the openid scope was requested.

Why the two-step dance? Answering the classic interview question

Why not just return the access token in step 5? Because step 5 travels through the front channel (browser redirects, URL bars, referrer headers, browser history, proxies). Anything there should be assumed observable. The code is useless on its own: redeeming it requires the back-channel call, which needs the client secret (confidential clients) or the PKCE verifier (everyone). The verifier never left the client, and an attacker who sniffed the code cannot reverse SHA-256 to get it. That is the whole point of PKCE: it binds the code to the instance of the client that started the flow.

Python: generating PKCE values
import base64, hashlib, secrets

def b64url(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")

code_verifier = b64url(secrets.token_bytes(32))          # 43 chars
code_challenge = b64url(hashlib.sha256(code_verifier.encode("ascii")).digest())
state = secrets.token_urlsafe(16)

print(code_verifier, code_challenge, state)

Use S256, never plain. The plain method sends the verifier itself as the challenge, which defeats the purpose if the authorization request leaks.

OAuth 2.0 Grant Types Compared (and Which Are Deprecated)

A "grant" is a way of obtaining an access token. Interviewers like asking you to choose one for a given client, so learn the decision, not just the list.

GrantUse it forUser involved?Status
Authorization code + PKCEWeb apps, SPAs, mobile, desktopYesRecommended default
Client credentialsService-to-service, cron jobs, backend workersNo (client acts as itself)Recommended
Refresh tokenGetting a new access token without re-loginNo (already granted)Recommended, with rotation for public clients
Device authorization (RFC 8628)Smart TVs, CLIs, devices without a browser or keyboardYes, on a second deviceRecommended for that niche
ImplicitOld SPAs: token returned directly in URL fragmentYesDeprecated (RFC 9700, OAuth 2.1)
Resource owner password credentialsApp collects username/password and sends to ASYesDeprecated; defeats the purpose of OAuth

Client credentials in practice

When your order service calls your payment service, there is no user. The order service authenticates with its own client_id and client_secret (or better, a signed JWT assertion or mTLS) and gets a token with scopes like payments:create.

POST /oauth/token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
Authorization: Basic base64(client_id:client_secret)

grant_type=client_credentials&scope=payments:create

Cache that token until shortly before expires_in rather than fetching one per request; token endpoints are commonly rate limited and become a hidden bottleneck.

Device flow, the "enter this code on your phone" pattern

The TV app calls /device_authorization, gets a user_code like WDJB-MJHT and a verification_uri, shows them on screen, and polls the token endpoint every few seconds. The user opens the URL on a phone, logs in and types the code. Streaming apps and the GitHub CLI use this pattern.

OpenID Connect: Adding Authentication on Top of OAuth

Plain OAuth gives the client an access token, but the client has no standard way to learn who the user is. Early "login with X" implementations called a profile API with the access token and trusted the result, which led to the confused deputy problem: a token issued to app A could be replayed to app B, which would log the attacker in as the victim. OIDC fixes this with the ID token, a JWT whose audience is explicitly the client.

To use OIDC, add openid to the scopes (plus profile, email as needed). You get:

  • ID token with claims: iss (issuer), sub (stable user ID at that issuer), aud (your client ID), exp, iat, nonce, auth_time, optionally email, name, amr (how they authenticated, e.g. password plus OTP).
  • /userinfo endpoint for additional profile claims, called with the access token.
  • Discovery at /.well-known/openid-configuration, a JSON document listing the authorize/token/userinfo endpoints and the jwks_uri where signing keys are published.
⚠️
Identify users by (iss, sub), not by emailEmails change and can be reassigned, and some providers allow unverified emails. The stable, unique key for a federated user is the pair of issuer plus subject. Store that in your users table and treat email as a mutable attribute.

ID token vs access token: who is the audience?

The ID token is for the client: it reads it once at login to create a session. The access token is for the API. Two bugs follow from mixing them up: sending the ID token to your API as a bearer token (the API then accepts tokens issued for other clients if it does not check aud), and the frontend decoding the access token to make UI decisions (it may be opaque, and its format is a contract between AS and API, not the client).

JWT Structure: Header, Payload, Signature

A JWT in compact form is three base64url-encoded segments joined by dots. Here is a decoded example of an access token your API might receive:

Header
{
  "alg": "RS256",
  "typ": "at+jwt",
  "kid": "2026-09-key-1"
}
Payload (claims)
{
  "iss": "https://auth.example.com",
  "sub": "user_8f3a21",
  "aud": "https://api.example.com",
  "exp": 1791620400,
  "iat": 1791619500,
  "nbf": 1791619500,
  "jti": "a7c1e0b2-55e4-4a1e-9d1b-3f0c7a9e2d11",
  "scope": "orders:read orders:write",
  "client_id": "food-app-android"
}

The signature is computed over the exact bytes base64url(header) + "." + base64url(payload) using the algorithm in the header. For RS256, the AS signs with its RSA private key; verifiers check with the public key, fetched from the JWKS URL and selected by kid.

ClaimMeaningWhat the verifier does
issIssuerMust exactly equal the expected issuer URL
audIntended recipientMust contain this API's identifier
expExpiry (Unix seconds)Reject if now > exp (allow 30 to 60 s clock skew)
nbf / iatNot before / issued atReject tokens from the future
subSubject (user or client)Used as the principal ID
jtiUnique token IDUsed for denylists and replay detection
scopeGranted permissionsChecked per endpoint

Signed is not encrypted

Paste any JWT into a base64 decoder and the payload is readable. A signed JWT (JWS) guarantees integrity and authenticity, not confidentiality. Never put PAN numbers, Aadhaar numbers, phone numbers or internal secrets in a JWT payload unless you use JWE (encrypted JWT), and even then prefer a reference ID. Also remember size: every claim is sent on every request, and many proxies cap headers at around 8 KB.

HS256 vs RS256 vs ES256

AlgorithmKey typeWho can verifyWho can mintUse when
HS256Shared HMAC secretAnyone with the secretAnyone with the secretIssuer and verifier are the same service
RS256RSA key pairAnyone with the public keyOnly the private key holderMany microservices verify; widest library support
ES256ECDSA P-256 key pairPublic key holdersPrivate key holderSame as RS256 with much smaller signatures
EdDSAEd25519Public key holdersPrivate key holderModern, fast; check library support first

With HS256 across 20 microservices, all 20 hold a secret that can forge an admin token. A compromise of the least-protected service compromises everyone. Asymmetric signing limits minting power to the AS.

How to Verify a JWT Correctly (Code in Python and Java)

The verification checklist, in order:

  1. Parse the header without trusting it. Read kid to select the key.
  2. Enforce an algorithm allowlist configured on the server, e.g. only RS256. Never let the token's alg choose the algorithm.
  3. Fetch the key from JWKS (cached, with refresh on unknown kid to support key rotation, and rate-limited so attackers cannot force refetch storms).
  4. Verify the signature.
  5. Validate claims: iss, aud, exp, nbf, and typ if your AS sets it.
  6. Authorize: check scopes/roles for this endpoint and, for user data, that sub owns the resource.
Python (PyJWT)
import jwt
from jwt import PyJWKClient

ISSUER = "https://auth.example.com"
AUDIENCE = "https://api.example.com"
jwks_client = PyJWKClient(f"{ISSUER}/.well-known/jwks.json", cache_keys=True)

def verify_access_token(token: str) -> dict:
    signing_key = jwks_client.get_signing_key_from_jwt(token)
    claims = jwt.decode(
        token,
        signing_key.key,
        algorithms=["RS256"],          # allowlist, never taken from the token
        audience=AUDIENCE,
        issuer=ISSUER,
        leeway=30,                     # seconds of clock skew
        options={"require": ["exp", "iat", "iss", "aud", "sub"]},
    )
    return claims

def require_scope(claims: dict, needed: str) -> None:
    if needed not in claims.get("scope", "").split():
        raise PermissionError(f"missing scope {needed}")
Java (Nimbus JOSE + JWT)
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.jwk.source.JWKSourceBuilder;
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier;
import com.nimbusds.jwt.proc.DefaultJWTProcessor;

import java.net.URL;
import java.util.Set;

public final class AccessTokenVerifier {
    private final DefaultJWTProcessor<SecurityContext> processor = new DefaultJWTProcessor<>();

    public AccessTokenVerifier(String issuer, String audience) throws Exception {
        JWKSource<SecurityContext> keys = JWKSourceBuilder
                .create(new URL(issuer + "/.well-known/jwks.json"))
                .build();                                   // cached, refreshes on unknown kid
        processor.setJWSKeySelector(
                new JWSVerificationKeySelector<>(JWSAlgorithm.RS256, keys));  // alg allowlist
        processor.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>(
                audience,
                new JWTClaimsSet.Builder().issuer(issuer).build(),   // exact-match claims
                Set.of("sub", "exp", "iat")));                       // required claims
    }

    public JWTClaimsSet verify(String token) throws Exception {
        return processor.process(token, null);   // throws on bad signature, exp, iss, aud
    }
}

In Spring Boot you would normally not write this yourself: spring-boot-starter-oauth2-resource-server with spring.security.oauth2.resourceserver.jwt.issuer-uri configures discovery, JWKS caching and iss/exp checks. Audience validation, however, is something you often have to add explicitly with a custom validator. Know that detail; it is a frequent follow-up.

Access Tokens vs Refresh Tokens: Lifetimes, Rotation and Revocation

The fundamental tension: self-contained JWT access tokens are fast (no database lookup per request) but cannot be revoked; server-side sessions are revocable but need a lookup. The access plus refresh token pair resolves it.

Access tokenRefresh token
Sent toResource server, on every requestAuthorization server only
Typical lifetime5 to 60 minutesDays to months, sliding or absolute
FormatOften JWT (or opaque)Usually opaque, stored server-side
RevocationHard; rely on short expiryEasy; delete the row
If stolenAttacker has access until expiryAttacker can mint access tokens until revoked

Refresh token rotation with reuse detection

For public clients, best practice is to issue a new refresh token on every refresh and invalidate the old one. If an old refresh token is ever presented again, either the legitimate client or an attacker is replaying it, and the AS cannot tell which, so it revokes the entire token family and forces re-login.

Pseudocode: refresh endpoint with rotation
def refresh(presented_token):
    row = db.refresh_tokens.find(hash(presented_token))
    if row is None:
        raise Unauthorized()
    if row.used:
        # Reuse detected: someone holds a copy. Kill the whole family.
        db.refresh_tokens.revoke_family(row.family_id)
        audit_log("refresh_token_reuse", row.user_id, row.family_id)
        raise Unauthorized()
    if row.expires_at < now() or row.family_revoked:
        raise Unauthorized()

    db.refresh_tokens.mark_used(row.id)
    new_rt = random_token()
    db.refresh_tokens.insert(hash(new_rt), family_id=row.family_id,
                             user_id=row.user_id, expires_at=row.absolute_expiry)
    access = sign_jwt(sub=row.user_id, scope=row.scope, ttl_minutes=10)
    return {"access_token": access, "refresh_token": new_rt, "expires_in": 600}

Note two details: store a hash of the refresh token, not the token, so a database leak does not leak live credentials; and keep an absolute expiry on the family so rotation does not create infinite sessions. In practice you also need a small grace window for concurrent refreshes (two browser tabs refreshing at once) or you will log out legitimate users.

Revoking access tokens before expiry

  • Keep them short (10 minutes is common). Revoking the refresh token then cuts access within one TTL.
  • Denylist by jti in Redis with TTL equal to the token's remaining life, checked at the gateway. This reintroduces a lookup, but only a cheap key-exists check.
  • Per-user "tokens valid after" timestamp: on password change or "log out of all devices", store min_iat for the user and reject tokens with iat before it.
  • Opaque tokens plus introspection for high-risk operations such as payouts, where per-request validation cost is acceptable.

Where to Store Tokens: Browser, Mobile and Services

ClientRecommended storageAvoid
Server-rendered web appTokens in server session; browser gets HttpOnly, Secure, SameSite=Lax cookieSending tokens to the browser at all
SPA (React/Angular)Backend-for-frontend (BFF) holding tokens; or access token in memory with refresh via HttpOnly cookielocalStorage / sessionStorage for refresh tokens
AndroidEncryptedSharedPreferences / Android KeystorePlain SharedPreferences, logs
iOSKeychainUserDefaults
Backend serviceIn memory, fetched with client credentials; secrets in a vaultHardcoded secrets in repo or Docker image

Cookies bring CSRF back into the picture; mitigate with SameSite attributes plus a CSRF token or checking the Origin header on state-changing requests. Tokens in JavaScript-accessible storage bring XSS risk. There is no free option, which is why the BFF pattern has become the commonly recommended default for browser apps handling sensitive data.

The Security Mistakes That Actually Get APIs Breached

Mistake 1

Trusting the alg header

Accepting "alg": "none", or the RS256-to-HS256 confusion attack where the attacker signs with HMAC using your public key as the secret. Fix: server-side algorithm allowlist, and keep keys typed.

Mistake 2

Not checking aud

Any token from your IdP, issued to any app (including an attacker's free-tier app on the same IdP), is accepted. Fix: always validate audience.

Mistake 3

Loose redirect URI matching

Prefix or wildcard matching lets attackers redirect codes to https://app.com.evil.io or an open redirect on your domain. Fix: exact string match of registered URIs.

Mistake 4

Missing state / nonce

Without state, an attacker can log a victim into the attacker's account (login CSRF). Without nonce, ID tokens can be replayed. Fix: generate, store and compare both.

Mistake 5

Decode instead of verify

Calling jwt.decode(token, options={"verify_signature": False}) "temporarily" and shipping it. Fix: code review rule and tests with tampered tokens.

Mistake 6

Long-lived tokens, no revocation

30-day JWT access tokens mean a leaked token from a log file works for a month. Fix: short access TTL, revocable refresh tokens.

A few more that show up in security reviews: logging full Authorization headers (scrub them in your logging middleware); putting tokens in query strings (they end up in access logs and referrers); using the same signing key forever (rotate keys and publish both old and new in JWKS during overlap); and authorizing only by scope while forgetting object-level checks. A token with orders:read must still only read that user's orders. Broken object-level authorization is the top item on the OWASP API Security Top 10 for a reason.

🚫
Do not build your own authorization server casuallyRolling your own login, MFA, password reset, token issuance and key rotation is a large, security-critical surface. For most products, use a mature provider or open-source server (Keycloak, Ory, Auth0, Cognito, Firebase Auth) and spend your effort on correct verification and authorization in your APIs.

How OAuth and JWT Come Up in Interviews

In backend and system design rounds at product companies, auth rarely appears as "explain OAuth" in isolation. It appears inside a design: "your API gateway authenticates requests, how?" or "how does the mobile app stay logged in for 30 days?" For a full design of a login system with OTP, sessions and SSO, see Design an Authentication & SSO System; for where token checks sit in the request path, see Design an API Gateway.

Questions to expect and strong answer outlines

  • "JWT or sessions?" Strong answer: sessions for a single web app (simple, revocable); JWT access tokens when many stateless services verify identity and you want to avoid a central lookup on every hop; and almost always a revocable refresh token behind them. Mention the revocation trade-off unprompted.
  • "How does service A call service B securely?" Client credentials grant with scoped tokens, or mTLS in a service mesh, plus propagating the end-user identity separately (token exchange, RFC 8693) if B needs to know the user.
  • "A user changed their password. How do you log out their other devices?" Revoke all refresh token families for the user, set a min_iat, and accept up to one access-token TTL of lag, or denylist active jtis if that lag is unacceptable.
  • "How do you rotate signing keys without downtime?" Publish the new public key in JWKS first, wait for verifier caches to refresh, start signing with the new kid, keep the old key published until all tokens signed with it have expired, then remove it.
  • "Where does verification happen?" At the gateway for coarse checks (signature, expiry, audience), and again in services for fine-grained authorization. Do not let internal services blindly trust an X-User-Id header unless the network path guarantees it came from the gateway.

Red flags interviewers notice

  • Saying "JWT is encrypted" or "OAuth is for login".
  • Proposing the implicit flow for a new SPA.
  • Storing refresh tokens in localStorage without acknowledging XSS.
  • No answer for revocation, or "we'll just make tokens expire in 24 hours".
  • Treating the token's scopes as complete authorization and skipping ownership checks.

If you are preparing for broader API rounds, pair this with REST API interview questions and the auth-related items in microservices interview questions. Protecting the token endpoint itself from brute force and credential stuffing is a rate limiting problem; Design a Rate Limiter covers that.

✅
A 60-second answer that impresses"OAuth 2.0 is delegated authorization; OIDC adds an ID token for authentication. For our mobile app I'd use authorization code with PKCE against our IdP, get a 10-minute JWT access token signed with RS256 and a rotating refresh token stored in the Keystore. The gateway verifies signature, iss, aud and exp using cached JWKS; services enforce scopes and resource ownership. Logout and password change revoke the refresh token family, so access dies within 10 minutes, and we denylist the jti for anything stricter."

Frequently Asked Questions

Is OAuth 2.0 an authentication protocol?▾
No. OAuth 2.0 is an authorization framework: it lets a client obtain an access token to call an API on a user's behalf. It says nothing standard about who the user is. OpenID Connect is the identity layer built on top of OAuth 2.0 that adds the ID token and the userinfo endpoint for authentication.
What is the difference between an ID token and an access token?▾
An ID token is a JWT meant for the client application; it proves the user authenticated and carries identity claims, and its audience is the client ID. An access token is meant for the resource server (API) and represents permission to call it. Never send an ID token to an API as a bearer credential, and never parse an opaque access token in the client.
Why is PKCE needed if I already have a client secret?▾
PKCE binds the authorization code to the client instance that started the flow, so a stolen code cannot be redeemed by an attacker. Mobile apps and SPAs cannot keep a secret at all, so PKCE is their only protection. OAuth 2.1 and current best practice recommend PKCE for every client, including confidential server-side ones.
Can a JWT be revoked before it expires?▾
Not by itself, because a JWT is verified locally without contacting the issuer. To revoke you either keep access tokens short-lived (5 to 15 minutes) and revoke the refresh token, maintain a denylist of token IDs (jti) checked on each request, or use opaque tokens with introspection for high-risk APIs.
Should I use HS256 or RS256 for JWTs?▾
Use an asymmetric algorithm such as RS256 or ES256 whenever more than one service verifies tokens, because verifiers only need the public key from the JWKS endpoint and cannot mint tokens. HS256 uses a shared secret, so every verifier can also forge tokens; it is acceptable only when the issuer and the single verifier are the same service.
Is it safe to store a JWT in localStorage?▾
It is readable by any JavaScript on the page, so a single XSS bug leaks the token. The commonly recommended pattern for browser apps is a backend-for-frontend that keeps tokens server-side and gives the browser an HttpOnly, Secure, SameSite cookie. If tokens must live in the browser, keep them in memory and short-lived.
Is the implicit grant still used?▾
It is deprecated. The OAuth 2.0 Security Best Current Practice (RFC 9700) and the OAuth 2.1 draft remove the implicit and resource owner password grants. SPAs should use the authorization code flow with PKCE instead.

Master System Design Interviews

Prepflix's system design track covers auth, API gateways and security trade-offs with mock interviews that probe exactly these follow-ups.

Explore the Prepflix Course →
Pranjal Jain
Pranjal Jain

Ex-Microsoft SDE · IIT Kanpur · Founder of Prepflix. Helps engineers crack startup and product company interviews across India.