OAuth 2.0 and JWT: How Authentication Actually Works
Every developer uses OAuth and JWTs but few understand the flow. Here's the full picture — without the hand-waving.
OAuth 2.0 Authorization Code Flow
User clicks 'Sign in with Google'
↓
Your app redirects to Google:
accounts.google.com/o/oauth2/auth?
client_id=YOUR_ID
redirect_uri=https://yourapp.com/callback
response_type=code
scope=openid email profile
state=RANDOM_STRING (CSRF protection)
↓
Google shows consent screen
User approves
↓
Google redirects to your callback:
yourapp.com/callback?code=AUTH_CODE&state=RANDOM_STRING
↓
Your server exchanges code for tokens:
POST accounts.google.com/o/oauth2/token
{ code, client_id, client_secret, redirect_uri }
↓
Google returns:
{ access_token, refresh_token, id_token (JWT) }
↓
You decode id_token to get user info
Create/login user in your database
Issue your own session
What Is a JWT
// A JWT is three base64url-encoded parts separated by dots:
// header.payload.signature
const header = { alg: 'HS256', typ: 'JWT' };
const payload = {
sub: 'user_123', // Subject (user ID)
email: 'user@test.com',
iat: 1700000000, // Issued at (Unix timestamp)
exp: 1700086400, // Expires at
};
// Signature = HMAC-SHA256(base64(header) + '.' + base64(payload), SECRET)
// Anyone can decode the payload — it's not encrypted
// But only the server can verify the signature
Creating and Verifying JWTs
import { SignJWT, jwtVerify } from 'jose';
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
// Create
async function createToken(userId: string): Promise<string> {
return new SignJWT({ sub: userId })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('24h')
.sign(secret);
}
// Verify
async function verifyToken(token: string) {
try {
const { payload } = await jwtVerify(token, secret);
return payload;
} catch {
return null; // Expired, tampered, or invalid
}
}
JWT vs Database Sessions
| JWT | Database Session | |
|---|---|---|
| Storage | Client (cookie/localStorage) | Server DB |
| Revocation | Hard (must wait for expiry) | Easy (delete row) |
| Scale | No DB lookup per request | DB lookup per request |
| Best for | Stateless APIs, microservices | Web apps needing instant revocation |
Refresh Token Pattern
// Short-lived access token + long-lived refresh token
const accessToken = await createToken(userId, '15m'); // Expires fast
const refreshToken = await createToken(userId, '30d'); // Lives longer
// Store refresh token in httpOnly cookie (not localStorage)
res.cookie('refreshToken', refreshToken, {
httpOnly: true, // JS can't read it
secure: true, // HTTPS only
sameSite: 'lax', // CSRF protection
maxAge: 30 * 24 * 60 * 60 * 1000,
});
// /api/refresh endpoint issues new access token using refresh token
PKCE (For Single-Page Apps)
// Generate code verifier and challenge
const codeVerifier = crypto.randomUUID() + crypto.randomUUID();
const codeChallenge = btoa(
String.fromCharCode(...new Uint8Array(
await crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier))
))
).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
// Include code_challenge in authorization URL
// Include code_verifier when exchanging code for tokens
// Prevents auth code interception attacks
Auth with JWT and OAuth ships pre-configured in the AI SaaS Starter Kit — Google + GitHub OAuth, session management, and protected routes. $99 at whoffagents.com.
Top comments (0)