Introduction
If you've built or debugged a login system in the last few years, you've almost certainly run into three acronyms that get thrown around interchangeably: JWT, OAuth 2.0, and OIDC. Despite being deeply related, they solve fundamentally different problems — and confusing them is one of the most common sources of security bugs in modern applications.
In 2026, with the proliferation of single-page applications (SPAs), native mobile apps, microservices, and third-party integrations, choosing the right combination of these technologies matters more than ever. Get it wrong, and you risk token leakage, session hijacking, or building an authentication system that simply doesn't scale.
This guide breaks down:
- What JWT, OAuth 2.0, and OIDC actually are (and aren't)
- How their flows work, with real code
- When to use each one — for web apps, mobile apps, and SPAs
- Security best practices and common mistakes to avoid
By the end, you'll have a clear mental model for architecting authentication and authorization in any modern application.
The Core Distinction (In One Sentence Each)
- JWT (JSON Web Token) — A compact, self-contained token format for securely transmitting claims (data) between two parties.
- OAuth 2.0 — An authorization framework that lets an application access resources on behalf of a user, without ever seeing the user's password.
- OIDC (OpenID Connect) — An authentication layer built on top of OAuth 2.0 that lets an application verify a user's identity ("who is this person?").
A helpful analogy: think of a hotel.
- OAuth 2.0 is the keycard system — it grants a specific level of access (your room, the gym, the pool) without giving you the master key.
- OIDC is the front desk verifying your ID before issuing that keycard — it establishes who you are.
- JWT is the physical keycard itself — a compact, tamper-evident object that encodes what you're allowed to access.
Let's unpack each one.
What Is JWT?
A JWT (JSON Web Token) is a URL-safe string format defined in RFC 7519 that represents a set of claims as a JSON object, digitally signed (and optionally encrypted).
A JWT has three parts, separated by dots:
header.payload.signature
Example decoded structure:
// Header
{
"alg": "RS256",
"typ": "JWT"
}
// Payload (claims)
{
"sub": "user_12345",
"name": "Asha Verma",
"iat": 1753234800,
"exp": 1753238400,
"iss": "https://auth.example.com",
"aud": "my-api"
}
The signature is computed over the encoded header and payload using a secret (HMAC) or a private key (RSA/ECDSA), which lets any recipient verify the token hasn't been tampered with.
Key Characteristics of JWT
- Stateless — the server doesn't need to store session data; all necessary info lives in the token.
- Self-contained — claims like user ID, roles, and expiration are embedded directly.
- Signed, not necessarily encrypted — anyone can read a JWT's payload (it's just Base64URL-encoded JSON), but they can't modify it without invalidating the signature.
⚠️ Important: JWT is just a format. It is not an authentication protocol by itself. You can use JWTs for session tokens, API keys, OAuth access tokens, or OIDC ID tokens — the format is reused everywhere, which is exactly why people conflate it with OAuth and OIDC.
Verifying a JWT (Node.js Example)
const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');
const client = jwksClient({
jwksUri: 'https://auth.example.com/.well-known/jwks.json'
});
function getKey(header, callback) {
client.getSigningKey(header.kid, (err, key) => {
const signingKey = key.getPublicKey();
callback(null, signingKey);
});
}
function verifyToken(token) {
return new Promise((resolve, reject) => {
jwt.verify(
token,
getKey,
{
algorithms: ['RS256'],
issuer: 'https://auth.example.com',
audience: 'my-api'
},
(err, decoded) => {
if (err) return reject(err);
resolve(decoded);
}
);
});
}
Notice the explicit checks: algorithms, issuer, and audience. Skipping any of these is a common — and dangerous — mistake (more on that later).
What Is OAuth 2.0?
OAuth 2.0 (RFC 6749) is an authorization framework that allows a third-party application to obtain limited access to a user's resources without exposing their credentials.
Think of "Sign in with Google to let App X read your Google Calendar." OAuth 2.0 is what makes that possible — App X never sees your Google password; it only receives a scoped access token.
Key Roles in OAuth 2.0
| Role | Description |
|---|---|
| Resource Owner | The user who owns the data |
| Client | The application requesting access |
| Authorization Server | Issues tokens after authenticating the user |
| Resource Server | Hosts the protected data/API |
The Modern Standard Flow: Authorization Code + PKCE
By 2026, the Authorization Code Flow with PKCE (Proof Key for Code Exchange) is the universally recommended flow for all client types — web, mobile, and SPA — as formalized in the OAuth 2.1 consolidation effort. The old Implicit Flow is deprecated because it exposes access tokens directly in the browser URL fragment, making them vulnerable to leakage via browser history, referrer headers, and logs.
Here's how Authorization Code + PKCE works:
- The client generates a random
code_verifierand derives acode_challenge(SHA-256 hash of it). - The client redirects the user to the authorization server with the
code_challenge. - The user authenticates and consents.
- The authorization server redirects back with a one-time
authorization_code. - The client exchanges the code plus the original
code_verifierfor an access token. - The authorization server verifies the
code_verifiermatches the earliercode_challengebefore issuing tokens.
// Step 1: Generate PKCE values
function generatePKCE() {
const codeVerifier = base64URLEncode(crypto.getRandomValues(new Uint8Array(32)));
const codeChallenge = base64URLEncode(sha256(codeVerifier));
return { codeVerifier, codeChallenge };
}
// Step 2: Redirect to authorization server
const authUrl = `https://auth.example.com/authorize?` +
`response_type=code&` +
`client_id=abc123&` +
`redirect_uri=https://app.example.com/callback&` +
`scope=openid profile email&` +
`code_challenge=${codeChallenge}&` +
`code_challenge_method=S256&` +
`state=${randomState}`;
// Step 5: Exchange code for tokens
const response = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: authorizationCode,
redirect_uri: 'https://app.example.com/callback',
client_id: 'abc123',
code_verifier: codeVerifier
})
});
PKCE was originally designed for public clients (mobile/SPA) that can't safely store a client secret, but it's now recommended for all clients as defense-in-depth against authorization code interception attacks.
Other OAuth 2.0 Grant Types (2026 Status)
| Grant Type | Use Case | 2026 Recommendation |
|---|---|---|
| Authorization Code + PKCE | Web, mobile, SPA | ✅ Recommended for all |
| Client Credentials | Machine-to-machine (M2M) | ✅ Recommended |
| Device Authorization Grant | Smart TVs, CLI tools | ✅ Recommended |
| Refresh Token | Long-lived sessions | ✅ Recommended (with rotation) |
| Implicit Flow | SPAs (legacy) | ❌ Deprecated — do not use |
| Resource Owner Password Credentials | First-party legacy apps | ❌ Deprecated — avoid |
What Is OIDC (OpenID Connect)?
OpenID Connect is a thin identity layer built directly on top of OAuth 2.0. While OAuth 2.0 answers "What can this app access?", OIDC answers "Who is this user?"
OIDC adds:
- A standardized ID Token — a JWT containing identity claims (
sub,email,name,iat,exp, etc.) - A standardized
/userinfoendpoint to fetch additional profile data - A
scope=openidrequirement that signals to the authorization server that identity information should be returned - Discovery documents (
/.well-known/openid-configuration) so clients can auto-configure endpoints and keys
ID Token vs Access Token — A Crucial Distinction
This is one of the most misunderstood parts of the entire stack:
| ID Token | Access Token | |
|---|---|---|
| Purpose | Proves user identity to the client | Grants access to a resource server/API |
| Audience | The client application | The API/resource server |
| Format | Always a JWT | Often a JWT, sometimes opaque |
| Who should validate it | The client (your app) | The API being called |
| Should APIs accept it as auth? | ❌ Never | ✅ Yes |
🚫 Common bug: Sending the ID token to your backend API as a Bearer token. The ID token was minted for your app, not for the API — its audience (
aud) claim won't match, and using it this way breaks the security model entirely.
Example ID Token Payload
{
"iss": "https://auth.example.com",
"sub": "user_12345",
"aud": "spa-client-id",
"exp": 1753238400,
"iat": 1753234800,
"email": "asha@example.com",
"email_verified": true,
"name": "Asha Verma"
}
Putting It Together: A Real-World Login Flow
Imagine a SaaS dashboard app that lets users "Sign in with Google" and then calls its own backend API.
- OIDC handles authentication — the app redirects to Google's authorization endpoint with
scope=openid email profile. - Google authenticates the user and returns an ID token (proves identity) and an access token (if the app also requested Google API scopes).
- The app decodes the ID token to display "Welcome, Asha" and creates an app session.
- When the SPA calls its own backend API, it uses its own access token (often a JWT issued by the app's authorization server), not Google's token.
- The backend API validates that JWT's signature, issuer, audience, and expiration on every request.
This is the layered architecture in a nutshell:
OIDC → authenticates the user (who are you?)
OAuth → authorizes access (what can you do?)
JWT → the token format carrying both of the above
Web, Mobile, and SPA: When to Use What
Traditional Server-Rendered Web Apps
- Best fit: Authorization Code Flow (with a confidential client secret) + OIDC for login.
- Store tokens server-side in an encrypted,
HttpOnly,Secure,SameSite=Laxsession cookie. - The browser never directly handles raw access/ID tokens.
// Express + a library like passport or openid-client
app.get('/callback', async (req, res) => {
const tokenSet = await client.callback(redirectUri, params, { code_verifier });
req.session.user = tokenSet.claims(); // server-side session
res.redirect('/dashboard');
});
Single-Page Applications (SPAs)
- Best fit: Authorization Code Flow with PKCE, using a Backend-for-Frontend (BFF) pattern where possible.
- Avoid storing access/refresh tokens in
localStorage— it's vulnerable to XSS-based theft. - The 2026 best practice is the BFF pattern: the SPA talks to its own lightweight backend, which holds tokens in a secure,
HttpOnlycookie session and proxies API calls. The browser never sees raw tokens at all. - If a BFF isn't feasible, use in-memory token storage (JS variables, not
localStorage) combined with silent refresh via refresh token rotation.
Native Mobile Apps
- Best fit: Authorization Code Flow with PKCE, using the system browser (via
ASWebAuthenticationSessionon iOS or Chrome Custom Tabs on Android) — never an embedded WebView, which can be manipulated to intercept credentials. - Use App-Claimed HTTPS redirect URIs (Universal Links / App Links) instead of custom URI schemes where possible, since custom schemes can be hijacked by malicious apps.
- Store tokens in the platform's secure storage: Keychain (iOS) or Keystore/EncryptedSharedPreferences (Android).
Machine-to-Machine (APIs, Microservices, CI/CD)
- Best fit: OAuth 2.0 Client Credentials Grant — no user is involved, so OIDC isn't relevant here.
- Tokens are typically short-lived JWTs signed by the authorization server, validated by each downstream service.
Security Considerations for 2026
The threat landscape has evolved, and so have the mitigations:
- PKCE is mandatory, not optional — even confidential clients should use it as defense-in-depth.
- Refresh Token Rotation — every time a refresh token is used, issue a new one and invalidate the old, so a stolen refresh token has a short window of usefulness (this is called refresh token reuse detection).
- DPoP (Demonstrating Proof of Possession) — an emerging standard (RFC 9449) that cryptographically binds a token to the client possessing a private key, mitigating stolen-token replay even over TLS.
- Short-lived access tokens — 5–15 minutes is common, paired with longer-lived, rotated refresh tokens.
audandissvalidation — always verify a token was issued for your service by a trusted issuer, not just that its signature is valid.- Algorithm allow-listing — explicitly specify accepted signing algorithms (
RS256,ES256) when verifying JWTs. Never acceptalg: none, and never let the algorithm be dictated by the token's own header without a server-side allow-list — this prevents the classic "algorithm confusion" attack. - FAPI 2.0 (Financial-grade API) — for high-assurance use cases (banking, healthcare), consider adopting FAPI 2.0 profiles, which layer stricter requirements onto OAuth/OIDC (mandatory PKCE, sender-constrained tokens, stricter redirect URI matching).
Best Practices Checklist
- ✅ Use OIDC for authentication, OAuth 2.0 scopes for authorization — don't overload one token with both roles unnecessarily.
- ✅ Always use Authorization Code Flow + PKCE, regardless of client type.
- ✅ Validate JWTs fully: signature,
iss,aud,exp, andnbf(not-before) claims. - ✅ Keep access tokens short-lived; rely on refresh token rotation for longevity.
- ✅ Use a BFF pattern for SPAs whenever feasible to keep tokens out of the browser's JS context.
- ✅ Rotate signing keys periodically and expose them via a JWKS endpoint.
- ✅ Log and monitor for refresh token reuse (a strong signal of token theft).
- ✅ Use HTTPS everywhere — tokens over plaintext HTTP are catastrophic.
Common Mistakes to Avoid
- ❌ Treating JWT as a security protocol. JWT is just a data format — signing doesn't mean encryption, and a valid signature doesn't mean the claims are trustworthy for your context.
- ❌ Using the Implicit Flow in 2026. It's deprecated for good reason — tokens leak via browser history and referrer headers.
- ❌ Storing tokens in
localStorage. This is directly exploitable by any successful XSS attack. PreferHttpOnlycookies or in-memory storage. - ❌ Sending the ID token to your API instead of the access token. The ID token's audience is the client app, not your backend.
- ❌ Skipping
aud/issvalidation. A signature check alone doesn't confirm the token was meant for your service. - ❌ Using embedded WebViews for OAuth on mobile. Always use the system browser or an in-app secure browser tab.
- ❌ Never expiring or rotating tokens. Long-lived, non-rotating tokens massively increase the blast radius of any leak.
🚀 Pro Tips
- Use a managed identity provider (Auth0, Okta, AWS Cognito, Keycloak, Ory) instead of hand-rolling OAuth/OIDC unless you have a very specific reason not to — the edge cases here are numerous and security-critical.
- Adopt the BFF pattern for SPAs in 2026 — it's rapidly becoming the default recommendation over pure browser-side token handling.
- Test token expiry paths explicitly — many production incidents come from apps that never properly tested what happens when a refresh token expires or is revoked mid-session.
- Use
stateandnonceparameters in every OAuth/OIDC flow to prevent CSRF and replay attacks — don't skip these even in "simple" implementations. - Prefer asymmetric signing (RS256/ES256) over HMAC (HS256) for JWTs whenever multiple services need to verify tokens — HMAC requires sharing a secret with every verifier, increasing risk.
📌 Key Takeaways
- JWT is a token format — useful, but not inherently an authentication or authorization protocol.
- OAuth 2.0 governs authorization — what an app can access on a user's behalf.
- OIDC governs authentication — confirming a user's identity, built on top of OAuth 2.0.
- In 2026, Authorization Code Flow + PKCE is the universal standard across web, mobile, and SPA clients.
- For SPAs, the BFF pattern is increasingly the recommended architecture to keep tokens out of client-side JavaScript.
- Always validate JWTs fully (
signature,iss,aud,exp) and never conflate ID tokens with access tokens.
Conclusion
JWT, OAuth 2.0, and OIDC aren't competing technologies — they're complementary layers in a well-designed authentication and authorization architecture. JWT gives you a compact, verifiable token format. OAuth 2.0 gives you a standardized way to grant scoped access. OIDC gives you a reliable way to establish user identity on top of that.
The mistakes that cause security incidents rarely come from these standards themselves — they come from misapplying them: using the wrong token in the wrong place, storing tokens insecurely, or skipping validation steps that seem "optional" until they're not. As you design authentication for your next web, mobile, or SPA project, keep the mental model simple: OIDC for who, OAuth for what, JWT for how.
Get that right, and the rest of the implementation — flows, storage, validation — follows naturally.