JWT vs Session vs OAuth: Which API Authentication Should You Use?
The Authentication Decision Tree
Choosing the right authentication method is one of the most impactful security decisions you'll make. Here's the quick answer:
Your App → Is it a traditional web app?
├── Yes → Server-side sessions (with cookies)
└── No → Is it a mobile app or SPA calling your own API?
├── Yes → Short-lived JWTs + Refresh tokens (HttpOnly cookies)
└── No → Is it third-party API access?
├── Yes → OAuth 2.0 (Authorization Code + PKCE)
└── No → API Keys (for server-to-server)
Option 1: Server-Side Sessions
The oldest and most battle-tested approach. The server stores session data; the client holds only a session ID in a cookie.
How It Works
1. User submits login form
2. Server verifies credentials
3. Server creates session in store (Redis/DB)
4. Server sends session ID as HttpOnly cookie
5. Browser sends cookie with every request
6. Server looks up session to identify user
Implementation
import session from 'express-session';
import RedisStore from 'connect-redis';
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
app.use(session({
store: new RedisStore({ client: redis }),
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
cookie: {
secure: true,
httpOnly: true,
sameSite: 'lax',
maxAge: 24 * 60 * 60 * 1000, // 24 hours
},
}));
// Login
app.post('/login', async (req, res) => {
const user = await verifyCredentials(req.body);
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
req.session.userId = user.id;
req.session.role = user.role;
res.json({ message: 'Logged in' });
});
// Protected route
app.get('/profile', (req, res) => {
if (!req.session.userId) return res.status(401).json({ error: 'Not authenticated' });
// ... fetch and return user data
});
// Logout — instantly invalidates the session
app.post('/logout', (req, res) => {
req.session.destroy(() => {
res.clearCookie('connect.sid');
res.json({ message: 'Logged out' });
});
});
Pros & Cons
| ✅ Pros | ❌ Cons |
|---|---|
| Instant revocation (delete session) | Requires session store (Redis) |
| Server controls all session data | Doesn't scale to multiple domains easily |
| Smaller cookie size (~32 bytes) | Stateful — every request hits the store |
| Battle-tested for 25+ years | CSRF protection required |
Option 2: JWT (JSON Web Tokens)
Stateless tokens that contain user claims. The server doesn't need to store anything — the token itself is the proof.
How It Works
1. User submits login credentials
2. Server verifies and creates signed JWT
3. Client stores JWT (cookie or memory)
4. Client sends JWT with every request
5. Server verifies signature — no database lookup needed
Implementation
import jwt from 'jsonwebtoken';
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET!;
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET!;
// Login — issue access + refresh tokens
app.post('/login', async (req, res) => {
const user = await verifyCredentials(req.body);
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
const accessToken = jwt.sign(
{ userId: user.id, role: user.role },
ACCESS_SECRET,
{ expiresIn: '15m' } // Short-lived!
);
const refreshToken = jwt.sign(
{ userId: user.id, tokenVersion: user.tokenVersion },
REFRESH_SECRET,
{ expiresIn: '7d' }
);
// ✅ Store tokens in HttpOnly cookies (not localStorage!)
res.cookie('access_token', accessToken, {
httpOnly: true, secure: true, sameSite: 'lax', maxAge: 900000,
});
res.cookie('refresh_token', refreshToken, {
httpOnly: true, secure: true, sameSite: 'lax',
maxAge: 7 * 86400000, path: '/api/auth/refresh',
});
res.json({ message: 'Logged in' });
});
// Middleware: Verify access token
function authenticate(req: Request, res: Response, next: NextFunction) {
const token = req.cookies.access_token;
if (!token) return res.status(401).json({ error: 'No token' });
try {
const payload = jwt.verify(token, ACCESS_SECRET) as JWTPayload;
req.user = payload;
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
// Refresh endpoint
app.post('/api/auth/refresh', async (req, res) => {
const refreshToken = req.cookies.refresh_token;
if (!refreshToken) return res.status(401).json({ error: 'No refresh token' });
try {
const payload = jwt.verify(refreshToken, REFRESH_SECRET) as RefreshPayload;
// ✅ Check token version (for revocation)
const user = await db.user.findById(payload.userId);
if (!user || user.tokenVersion !== payload.tokenVersion) {
return res.status(401).json({ error: 'Token revoked' });
}
// Issue new access token
const newAccessToken = jwt.sign(
{ userId: user.id, role: user.role },
ACCESS_SECRET,
{ expiresIn: '15m' }
);
res.cookie('access_token', newAccessToken, {
httpOnly: true, secure: true, sameSite: 'lax', maxAge: 900000,
});
res.json({ message: 'Token refreshed' });
} catch {
return res.status(401).json({ error: 'Invalid refresh token' });
}
});
JWT Security Rules
- Never store in localStorage — vulnerable to XSS. Use HttpOnly cookies.
- Keep access tokens short-lived — 15 minutes max.
- Use refresh token rotation — new refresh token with each refresh.
- Include tokenVersion — increment on password change/logout to revoke all tokens.
- Validate algorithm — prevent
alg: noneattacks.
Option 3: OAuth 2.0
For third-party access ("Login with Google") or when external apps need to call your API on behalf of users.
When to Use OAuth vs JWT
| Scenario | Best Choice |
|---|---|
| Your frontend → Your API | Sessions or JWT |
| "Login with Google" button | OAuth 2.0 (OIDC) |
| Third-party app → Your API | OAuth 2.0 |
| Mobile app → Your API | JWT with PKCE flow |
| Microservice → Microservice | JWT or mTLS |
| CLI tool → API | OAuth 2.0 Device Flow |
Comparison Summary
| Feature | Sessions | JWT | OAuth 2.0 |
|---|---|---|---|
| Stateful/Stateless | Stateful | Stateless | Depends |
| Instant revocation | ✅ Easy | ❌ Hard (needs denylist) | ✅ Via provider |
| Scalability | Needs shared store | ✅ No server state | ✅ Delegated |
| Cross-domain | ❌ Difficult | ✅ Easy | ✅ Designed for it |
| XSS risk | Low (HttpOnly cookie) | High if in localStorage | Varies |
| CSRF risk | Yes (needs tokens) | No (if in headers) | Varies |
| Complexity | Low | Medium | High |
| Best for | Traditional web apps | SPAs, mobile, microservices | Third-party access |
The safest option for most web applications: Server-side sessions with Redis. If you need stateless tokens for mobile/SPA: JWT in HttpOnly cookies with short expiry and refresh rotation.
Advertisement
Free Security Tools
Try our tools now
Expert Services
Get professional help
OWASP Top 10
Learn the top risks
Related Articles
Threat Modeling for Developers: STRIDE, PASTA & DREAD with Practical Examples
Threat modeling is the most cost-effective security activity — finding design flaws before writing code. This guide covers STRIDE, PASTA, and DREAD methodologies with real-world examples for web, API, and cloud applications.
Building a Security Champions Program: Scaling Security Across Dev Teams
Security teams can't review every line of code. Security Champions embed security expertise in every development team. This guide covers program design, champion selection, training, metrics, and sustaining engagement.
The Ultimate Secure Code Review Checklist for 2025
A comprehensive, language-agnostic checklist for secure code reviews. Use this as your team's standard for catching vulnerabilities before they reach production.