Authentication is one of the most critical parts of any web application. In 2025, the landscape has evolved significantly beyond simple username/password + sessions. This article explores production-ready, secure, and scalable authentication patterns in Node.js and Express.js and the best practices used by companies like Vercel, Supabase, Clerk (internally), and many startups building secure authentication systems.

The Death of Traditional Sessions (And Why That’s Good)
Traditional cookie-based sessions with express-session + Redis/MemoryStore are still common, but they’re increasingly being replaced by stateless JWT + HttpOnly cookies or token-based patterns.
Problems with Traditional Sessions
- Server-side state (hard to scale horizontally)
- Requires sticky sessions in load balancers
- Vulnerable to CSRF if not carefully mitigated
- Poor mobile/API experience
JWT in HttpOnly Cookies (Recommended for Most Apps)
This is the de facto standard in 2025 for web applications needing both security and good UX.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
// middleware/auth.js
import jwt from 'jsonwebtoken';
import { promisify } from 'util';
const verifyJwt = promisify(jwt.verify);
export const authenticateToken = async (req, res, next) => {
const token = req.cookies.access_token;
if (!token) {
return res.status(401).json({ message: 'Access token required' });
}
try {
const payload = await verifyJwt(token, process.env.JWT_SECRET);
req.user = payload;
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ message: 'Token expired' });
}
return res.status(403).json({ message: 'Invalid token' });
}
};
|
Secure Cookie Configuration
1
2
3
4
5
6
7
|
// Set access token (short-lived: 15 minutes)
res.cookie('access_token', accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict', // or 'lax' if you need third-party login
maxAge: 15 * 60 * 1000 // 15 minutes
});
|
Refresh Token Rotation + Reuse Detection (The Gold Standard)
Never use long-lived JWTs. Use short-lived access tokens + rotating refresh tokens.
1
2
3
4
5
6
7
8
|
// Generate rotating refresh token
function generateRefreshToken(userId, refreshTokenId) {
return jwt.sign(
{ sub: userId, jti: refreshTokenId, type: 'refresh' },
process.env.REFRESH_SECRET,
{ expiresIn: '7d' }
);
}
|
Database Schema (Prisma example)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
refreshTokens RefreshToken[]
}
model RefreshToken {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tokenHash String @unique // hashed version stored
expiresAt DateTime
createdAt DateTime @default(now())
revokedAt DateTime?
@@index([userId])
}
|
Refresh Token Endpoint with Rotation & Reuse Detection
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
|
app.post('/api/auth/refresh', async (req, res) => {
const refreshToken = req.cookies.refresh_token;
if (!refreshToken) return res.sendStatus(401);
let payload;
try {
payload = jwt.verify(refreshToken, process.env.REFRESH_SECRET);
} catch (err) {
return res.sendStatus(403);
}
// Check if token was revoked or reused
const storedToken = await prisma.refreshToken.findUnique({
{
where: { id: payload.jti }
});
if (!storedToken || storedToken.revokedAt) {
// Possible token theft!
await prisma.refreshToken.updateMany({
where: { userId: payload.sub },
data: { revokedAt: new Date() }
});
res.clearCookie('refresh_token');
return res.status(403).json({ message: 'Token compromised' });
}
if (storedToken.expiresAt < new Date()) {
return res.sendStatus(403);
}
// Rotate refresh token
const newRefreshTokenId = crypto.randomUUID();
const newRefreshToken = generateRefreshToken(payload.sub, newRefreshTokenId);
// Hash and store new token
const hashedToken = await hashToken(newRefreshToken);
await prisma.refreshToken.create({
data: {
id: newRefreshTokenId,
userId: payload.sub,
tokenHash: hashedToken,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
}
});
// Revoke old token
await prisma.refreshToken.update({
where: { id: payload.jti },
data: { revokedAt: new Date() }
});
// Issue new access token
const accessToken = generateAccessToken(payload.sub);
res.cookie('access_token', accessToken, accessCookieOptions);
res.cookie('refresh_token', newRefreshToken, refreshCookieOptions);
res.json({ message: 'Tokens renewed' });
});
|
Silent Authentication & Token Renewal (For SPA UX)
Keep users logged in without page refreshes.
1
2
3
4
5
6
7
8
9
10
|
// Frontend: Silent refresh before access token expires (e.g., at 14 minutes)
setInterval(async () => {
try {
const res = await fetch('/api/auth/refresh', { credentials: 'include' });
if (!res.ok) throw new Error('Refresh failed');
} catch (err) {
// Redirect to login
window.location.href = '/login';
}
}, 14 * 60 * 1000); // Every 14 minutes
|
Magic Links (Passwordless Auth) - 2025 Favorite
Extremely popular for developer tools and low-friction apps.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
app.post('/api/auth/magic-link', async (req, res) => {
const { email } = req.body;
const user = await prisma.user.findUnique({ where: { email } });
if (!user) return res.status(200).json({ message: 'If email exists, link sent' });
const token = crypto.randomBytes(32).toString('hex');
const hashedToken = await bcrypt.hash(token, 12);
await prisma.magicToken.create({
data: {
userId: user.id,
tokenHash: hashedToken,
expiresAt: new Date(Date.now() + 15 * 60 * 1000) // 15 min
}
});
const link = `${process.env.APP_URL}/auth/magic?token=${token}&email=${encodeURIComponent(email)}`;
await sendEmail(email, 'Your Magic Login Link', `Click here: ${link}`);
res.json({ message: 'Magic link sent' });
});
|
Passkeys / WebAuthn (The Future (Already Here)
Hardware-backed, phishing-resistant authentication.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
// Using simplewebauthn library (recommended)
import { generateRegistrationOptions, verifyRegistrationResponse } from '@simplewebauthn/server';
app.get('/api/auth/passkey/register/options', async (req, res) => {
const user = req.user; // from session or magic link
const options = generateRegistrationOptions({
rpName: 'My Awesome App',
rpID: 'localhost',
userID: user.id,
userName: user.email,
attestationType: 'none',
excludeCredentials: [], // existing credentials
});
// Store challenge in DB or session
await prisma.challenge.create({
data: {
userId: user.id,
challenge: options.challenge,
type: 'registration'
}
});
res.json(options);
});
|
Social OAuth2 with Proper Implementation
Never trust the provider’s ID token alone. Always verify with their API.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
// Example: Google OAuth2
app.get('/auth/google/callback', async (req, res) => {
const { code } = req.query;
const { tokens } = await oauth2Client.getToken(code);
// Verify ID token properly
const ticket = await client.verifyIdToken({
idToken: tokens.id_token,
audience: process.env.GOOGLE_CLIENT_ID
});
const payload = ticket.getPayload();
let user = await prisma.user.findUnique({ where: { email: payload.email } });
if (!user) {
user = await prisma.user.create({
data: {
email: payload.email,
name: payload.name,
avatar: payload.picture,
emailVerified: payload.email_verified
}
});
}
// Issue your own tokens
const accessToken = generateAccessToken(user.id);
// ... set cookies
});
|
Rate Limiting & Brute Force Protection
Essential for any auth endpoint.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
import rateLimit from 'express-rate-limit';
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts per window per IP
message: 'Too many login attempts',
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => {
return req.ip || req.headers['x-forwarded-for'];
}
});
app.post('/api/auth/login', loginLimiter, loginHandler);
|
Multi-Factor Authentication (TOTP + Backup Codes)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
// Enable TOTP
app.post('/api/auth/mfa/totp/enable', authenticateToken, async (req, res) => {
const secret = speakeasy.generateSecret({
name: `MyApp (${req.user.email})`
});
await prisma.user.update({
where: { id: req.user.id },
data: {
totpSecret: secret.base32,
backupCodes: generateBackupCodes() // 10 one-time codes
}
});
res.json({
secret: secret.base32,
otpauth_url: secret.otpauth_url
});
});
|
Best Practices Summary (2025)
| Pattern |
Best For |
Security Level |
Phishing Resistant |
Passwordless |
| JWT + HttpOnly Cookies + Refresh Rotation |
Most production web apps |
Very High |
Yes |
Yes |
| Magic Links |
SaaS, dev tools, internal apps |
High |
Yes |
Yes |
| Passkeys (WebAuthn / FIDO2) |
Banking, crypto, enterprise |
Highest |
Yes |
Yes |
| Traditional Sessions + Redis |
Legacy apps, simple projects |
Medium |
No |
No |
| Social OAuth2 (Google, GitHub, etc.) |
Consumer apps, quick signup |
High (if implemented correctly) |
No |
Yes |
Conclusion: Final Recommendations
- Never store refresh tokens in plain text → always hash them
- Use HttpOnly, Secure, SameSite cookies → no localStorage JWTs
- Implement refresh token rotation + reuse detection
- Rate limit all auth endpoints
- Use short-lived access tokens (15-60 min)
- Consider passkeys for new applications
Authentication security is not about choosing one perfect method; it’s about defense in depth, proper implementation, and staying updated with evolving threats.
Stay Adaptive, Stay secure! 🔐
Author
If you liked the above story, you can
to keep me energized for writing stories like this for you.