The Complete OAuth 2.0 and JWT Guide for Web Developers
Three years ago, I implemented authentication for BirJob from scratch. I chose "just use JWTs" because that is what every tutorial recommended. Within a month, I had built a system where tokens never expired, there was no way to revoke them, and refresh tokens were stored in localStorage. I had built a textbook example of how not to do authentication.
Since then, I have rebuilt our auth system twice, read the OAuth 2.0 RFC cover to cover, and integrated with a dozen identity providers. This guide is what I wish I had when I started — a practical, opinionated walkthrough of OAuth 2.0 and JWT that covers not just the happy path, but the security pitfalls that tutorials skip.
1. The Problem: Why Authentication is Hard
Authentication seems simple on the surface. User provides credentials, server verifies them, done. But the complexity explodes when you consider:
- How do you handle "Login with Google"?
- How does your mobile app authenticate differently from your web app?
- How do you let third-party apps access user data without sharing passwords?
- How do you revoke access when a user changes their password?
- How do you prevent token theft in a browser?
OAuth 2.0 exists to solve these problems. RFC 6749, published in 2012, defines the OAuth 2.0 authorization framework. Despite being over a decade old, it remains the foundation of modern authentication on the web. According to Auth0's 2024 State of Identity report, 92% of web applications use some form of OAuth 2.0 for social login or API authorization.
2. OAuth 2.0: The Mental Model
OAuth 2.0 is not an authentication protocol — it is an authorization framework. This distinction matters. OAuth tells your application "this user has granted permission for you to access their Google profile." It does not, by itself, tell you "this user is John Smith with email john@example.com." That is what OpenID Connect (OIDC) adds on top of OAuth.
The Four Roles
| Role | What It Is | Example |
|---|---|---|
| Resource Owner | The user who owns the data | A Google user who wants to log into your app |
| Client | Your application | BirJob web application |
| Authorization Server | Issues tokens after user consents | Google's OAuth server (accounts.google.com) |
| Resource Server | Hosts the protected data | Google's People API |
Grant Types: Which One to Use
| Grant Type | Use Case | Security Level | 2026 Recommendation |
|---|---|---|---|
| Authorization Code + PKCE | Web apps, mobile apps, SPAs | High | Use this for everything |
| Client Credentials | Server-to-server communication | High | Use for machine-to-machine |
| Device Code | Smart TVs, CLI tools, IoT | Medium | Use when no browser available |
| Implicit (deprecated) | Was used for SPAs | Low | Never use — replaced by Auth Code + PKCE |
| Resource Owner Password (deprecated) | Was used for trusted first-party apps | Low | Never use |
The OAuth 2.0 Security Best Current Practice document explicitly deprecates the Implicit and Password grant types. If you are starting a new project in 2026, use Authorization Code with PKCE for everything. Full stop.
3. Authorization Code Flow with PKCE: Step by Step
PKCE (Proof Key for Code Exchange, pronounced "pixie") was originally designed for mobile apps but is now recommended for all clients, including server-side web apps. It prevents authorization code interception attacks without requiring a client secret on the front end.
The Flow
1. Client generates a random "code_verifier" (43-128 chars)
2. Client creates "code_challenge" = BASE64URL(SHA256(code_verifier))
3. Client redirects user to authorization server with code_challenge
GET https://accounts.google.com/o/oauth2/v2/auth?
response_type=code
&client_id=YOUR_CLIENT_ID
&redirect_uri=https://birjob.com/auth/callback
&scope=openid email profile
&state=RANDOM_STATE_VALUE
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
4. User logs in and consents
5. Authorization server redirects back with an authorization code
GET https://birjob.com/auth/callback?
code=4/0AX4XfWh...
&state=RANDOM_STATE_VALUE
6. Client exchanges code for tokens (server-side)
POST https://oauth2.googleapis.com/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=4/0AX4XfWh...
&redirect_uri=https://birjob.com/auth/callback
&client_id=YOUR_CLIENT_ID
&client_secret=YOUR_CLIENT_SECRET
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
7. Server receives tokens:
{
"access_token": "ya29.a0AfH6SM...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "1//0eVo...",
"id_token": "eyJhbGci..."
}
Implementation in Next.js
// lib/auth.ts
import crypto from 'crypto';
export function generatePKCE() {
const verifier = crypto.randomBytes(32).toString('base64url');
const challenge = crypto
.createHash('sha256')
.update(verifier)
.digest('base64url');
return { verifier, challenge };
}
// app/api/auth/login/route.ts
export async function GET() {
const { verifier, challenge } = generatePKCE();
const state = crypto.randomBytes(16).toString('hex');
// Store verifier and state in an HTTP-only cookie or server-side session
const params = new URLSearchParams({
response_type: 'code',
client_id: process.env.GOOGLE_CLIENT_ID!,
redirect_uri: `${process.env.BASE_URL}/api/auth/callback`,
scope: 'openid email profile',
state,
code_challenge: challenge,
code_challenge_method: 'S256',
});
return Response.redirect(
`https://accounts.google.com/o/oauth2/v2/auth?${params}`
);
}
// app/api/auth/callback/route.ts
export async function GET(request: Request) {
const url = new URL(request.url);
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
// Verify state matches what we stored
// Retrieve code_verifier from session/cookie
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code!,
redirect_uri: `${process.env.BASE_URL}/api/auth/callback`,
client_id: process.env.GOOGLE_CLIENT_ID!,
client_secret: process.env.GOOGLE_CLIENT_SECRET!,
code_verifier: storedVerifier,
}),
});
const tokens = await tokenResponse.json();
// Decode id_token to get user info, create session
}
4. JWTs: What They Are and What They Are Not
A JSON Web Token (JWT) is a compact, URL-safe format for representing claims. It consists of three parts separated by dots: header.payload.signature. Each part is Base64URL-encoded.
// Header
{
"alg": "RS256",
"typ": "JWT",
"kid": "2024-key-1"
}
// Payload
{
"sub": "user_123",
"email": "user@example.com",
"name": "John Doe",
"iat": 1679012345,
"exp": 1679015945,
"iss": "https://birjob.com",
"aud": "birjob-web"
}
// Signature
RSASHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
privateKey
)
Common JWT Mistakes
| Mistake | Why It Is Dangerous | Correct Approach |
|---|---|---|
| Storing JWTs in localStorage | Vulnerable to XSS attacks — any script can read them | Use HTTP-only, Secure, SameSite cookies |
| Never expiring tokens | A stolen token grants permanent access | Short-lived access tokens (15-60 min) + refresh tokens |
Using alg: "none" |
Disables signature verification entirely | Always verify the algorithm matches your expectation |
| Putting sensitive data in payload | JWTs are encoded, not encrypted — anyone can decode them | Only include user ID and roles; fetch sensitive data from DB |
| Using symmetric signing (HS256) for public APIs | Anyone who can verify can also create tokens | Use asymmetric signing (RS256/ES256) for multi-service |
Not validating iss and aud claims |
Tokens from other services could be accepted | Always validate issuer and audience |
The JWT RFC (7519) is surprisingly readable. I recommend every developer implementing JWT-based auth read at least sections 4 (JWT Claims) and 7 (Creating and Validating JWTs).
5. Access Tokens vs Refresh Tokens vs ID Tokens
One of the most confusing aspects of OAuth + JWT is the distinction between different token types. Let me clarify:
| Token Type | Purpose | Lifetime | Format | Where to Store |
|---|---|---|---|---|
| Access Token | Authorize API requests | 15-60 minutes | JWT or opaque string | Memory (SPA) or HTTP-only cookie |
| Refresh Token | Get new access tokens | Days to months | Opaque string (never JWT) | HTTP-only, Secure cookie or server-side storage |
| ID Token | Identify the user (OIDC) | Minutes to hours | Always JWT | Consume on login, then discard |
Critical rule: Refresh tokens must be stored securely and must be rotatable. When a refresh token is used to get a new access token, the authorization server should issue a new refresh token and invalidate the old one. This is called refresh token rotation, and it limits the damage of a stolen refresh token.
// Refresh token rotation flow
POST /oauth/token
{
"grant_type": "refresh_token",
"refresh_token": "old_refresh_token_abc",
"client_id": "your_client_id"
}
Response:
{
"access_token": "new_access_token_xyz",
"refresh_token": "new_refresh_token_def", // NEW refresh token
"expires_in": 900
}
// old_refresh_token_abc is now invalid
6. Token Storage: The Definitive Guide
Where you store tokens determines your application's security posture. This is the most commonly botched aspect of JWT implementation.
For Server-Rendered Apps (Next.js, Django, Rails)
Use server-side sessions. Store a session ID in an HTTP-only cookie. Keep tokens on the server.
Set-Cookie: session_id=abc123;
HttpOnly; // JavaScript cannot access
Secure; // Only sent over HTTPS
SameSite=Lax; // CSRF protection
Path=/;
Max-Age=86400
For SPAs (React, Vue, Angular)
The safest approach for SPAs is the Backend-for-Frontend (BFF) pattern: your SPA talks to a thin backend proxy that handles token storage. The SPA never sees the actual tokens.
// BFF pattern
SPA → BFF (your server) → API / Auth Server
↓
Stores tokens in
HTTP-only cookies
between SPA and BFF
If a BFF is not feasible, store the access token in memory (a JavaScript variable, not localStorage) and the refresh token in an HTTP-only cookie. The access token will be lost on page refresh, but the refresh token cookie will persist and can silently get a new access token.
For Mobile Apps
Use the platform's secure storage: Keychain on iOS, EncryptedSharedPreferences on Android. Never store tokens in plain text files or regular shared preferences.
7. Token Revocation: The Unsolved Problem
JWTs are stateless by design — the server does not need to store them to validate them. But this creates a problem: how do you revoke a JWT before it expires? If a user logs out or their account is compromised, you need to invalidate their token immediately.
Approaches to Token Revocation
| Approach | How It Works | Pros | Cons |
|---|---|---|---|
| Short-lived tokens | Access tokens expire in 5-15 minutes | Simple, no server state | Revocation has a delay (up to token lifetime) |
| Token blacklist | Store revoked token IDs in Redis | Immediate revocation | Requires server-side state (defeats purpose of JWT) |
| Token versioning | Store a "version" per user; reject tokens with old versions | Revokes all tokens at once | Requires DB lookup per request |
| Opaque tokens | Do not use JWTs — use random strings looked up in DB | Full control, immediate revocation | Every request requires DB lookup |
My recommendation: use short-lived JWTs (15 minutes) for access tokens and opaque refresh tokens stored in your database. This gives you the stateless benefits of JWTs for most requests while maintaining full control over refresh tokens. When a user logs out, delete their refresh token from the database. The access token will expire naturally within 15 minutes.
8. OpenID Connect: Authentication on Top of OAuth
OpenID Connect (OIDC) is a thin identity layer on top of OAuth 2.0 that adds standardized authentication. While OAuth tells you "this user authorized access to their data," OIDC tells you "this user is John Smith with email john@example.com."
The key additions OIDC makes:
- ID Token: A JWT containing user identity claims (name, email, picture)
- UserInfo Endpoint: A standard endpoint to fetch user profile data
- Discovery Document: A JSON document at
/.well-known/openid-configurationthat describes the provider's endpoints and capabilities - Standard Scopes:
openid,profile,email,address,phone
When you use "Login with Google" or "Login with GitHub," you are using OIDC. The OIDC specification standardizes how identity providers communicate user information, so your application can work with any compliant provider without custom integration code.
9. Security Checklist
Here is a comprehensive security checklist for OAuth 2.0 and JWT implementations, based on OWASP's JWT security recommendations:
- Always use HTTPS — tokens in transit must be encrypted
- Validate the
stateparameter — prevents CSRF attacks on the OAuth callback - Use PKCE for all clients — even server-side apps, as defense in depth
- Validate JWT signatures — never trust an unverified JWT
- Check
exp,iss,audclaims — reject tokens that are expired, from wrong issuer, or for wrong audience - Use RS256 or ES256 — asymmetric algorithms for multi-service architectures
- Rotate signing keys — use the
kidheader to support key rotation without downtime - Implement refresh token rotation — issue new refresh token on each use
- Detect refresh token reuse — if a previously rotated token is used, revoke all tokens for that user (potential theft)
- Set appropriate token lifetimes — access tokens: 15-60 minutes, refresh tokens: 7-30 days
- Never store tokens in localStorage — use HTTP-only cookies or server-side sessions
- Implement logout properly — revoke refresh tokens, clear cookies, optionally call the provider's logout endpoint
10. My Opinionated Take
Stop rolling your own auth. Seriously. Use a proven library or service. NextAuth.js (now Auth.js), Passport.js, Django's built-in auth, Supabase Auth, Clerk, Auth0 — these have been battle-tested by millions of applications. The likelihood that your custom auth implementation is more secure than these is approximately zero.
JWTs are overused. For most server-rendered web applications, traditional server-side sessions are simpler, more secure, and easier to revoke. JWTs shine in microservices architectures where multiple services need to validate tokens without calling a central auth server. If you have a monolith, you probably do not need JWTs.
OAuth is not just for "Login with Google." The same OAuth patterns work for your own API. If you have a mobile app, a web app, and third-party integrations all accessing the same API, OAuth gives you a consistent, secure framework for all of them.
The BFF pattern is the future for SPAs. The days of storing tokens in the browser are numbered. The Backend-for-Frontend pattern provides proper security without sacrificing developer experience. Frameworks like Next.js make this trivial with server-side API routes.
11. Action Plan: Implementing Auth in Your Next Project
Step 1: Choose your approach
- Server-rendered app → server-side sessions with Auth.js or equivalent
- SPA + API → BFF pattern with short-lived JWTs
- Mobile app → Authorization Code + PKCE with secure storage
- Microservices → JWTs with asymmetric signing (RS256/ES256)
Step 2: Set up the identity provider
- For social login → register OAuth apps with Google, GitHub, etc.
- For your own auth → use Supabase Auth, Clerk, or Auth0
- For enterprise → consider SAML 2.0 or OIDC with Azure AD / Okta
Step 3: Implement securely
- Follow the security checklist above
- Use PKCE for all flows
- Store tokens in HTTP-only cookies
- Implement refresh token rotation
Step 4: Test
- Test token expiration and refresh flows
- Test logout (tokens are actually revoked)
- Test with expired, malformed, and stolen tokens
- Run a security audit with tools like OWASP ZAP
Sources
- RFC 6749 — OAuth 2.0 Authorization Framework
- RFC 7519 — JSON Web Token (JWT)
- OAuth 2.0 Security Best Current Practice
- OpenID Connect Core 1.0 Specification
- Auth0 — State of Identity 2024
- OWASP — JWT Security Cheat Sheet
I'm Ismat, and I build BirJob — Azerbaijan's job aggregator scraping 80+ sources daily.
