TL;DR
The #1 authentication security best practice is using established auth libraries instead of rolling your own. These 7 practices take about 60 minutes to implement. Focus on: hashing passwords with bcrypt or Argon2, storing sessions in HttpOnly cookies, rate limiting your login endpoint, and requiring MFA for sensitive accounts.
"Authentication is the front door to your application. A weak lock invites every attacker on the internet to try the handle."
Rule 1: Use Established Auth Libraries
Nobody builds their own TLS stack. Don't build your own auth from scratch either:
| Framework | Recommended Library |
|---|---|
| Next.js | NextAuth.js (Auth.js) |
| React SPA | Auth0, Clerk, Firebase Auth |
| Node.js | Passport.js, express-session |
| Full-stack | Supabase Auth, Firebase Auth |
Don't build custom auth unless you have specific security expertise. Homegrown auth implementations are where most authentication vulnerabilities come from.
Best Practice 1: Hash Passwords Correctly 5 min
Never store plain text passwords. Use bcrypt or Argon2: they're slow by design, which is exactly what you want:
import bcrypt from 'bcrypt';
const SALT_ROUNDS = 12; // Higher = more secure but slower
// Hash password before storing
async function hashPassword(password) {
return await bcrypt.hash(password, SALT_ROUNDS);
}
// Verify password during login
async function verifyPassword(password, hash) {
return await bcrypt.compare(password, hash);
}
// Registration
async function registerUser(email, password) {
const hashedPassword = await hashPassword(password);
await db.user.create({
data: {
email,
password: hashedPassword, // Store the hash, never the plain password
},
});
}
// Login
async function loginUser(email, password) {
const user = await db.user.findUnique({ where: { email } });
if (!user) {
// Use same error for missing user and wrong password
throw new Error('Invalid credentials');
}
const valid = await verifyPassword(password, user.password);
if (!valid) {
throw new Error('Invalid credentials');
}
return user;
}
Best Practice 2: Secure Session Management 10 min
Session cookies are worthless if misconfigured. Here's the setup that actually locks things down:
import session from 'express-session';
import RedisStore from 'connect-redis';
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET, // Long random string
name: 'sessionId', // Custom name (not 'connect.sid')
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true, // Prevents JavaScript access
secure: true, // HTTPS only
sameSite: 'lax', // CSRF protection
maxAge: 24 * 60 * 60 * 1000, // 24 hours
},
}));
Session Cookie Requirements:
- HttpOnly: true (prevents XSS from stealing cookies)
- Secure: true (HTTPS only in production)
- SameSite: 'lax' or 'strict' (CSRF protection)
- Reasonable expiry (24 hours or less for sensitive apps)
- Stored server-side (Redis, database) not just in cookie
Best Practice 3: Implement Rate Limiting 5 min
Without a rate limit, your login endpoint accepts unlimited guesses. Five attempts per 15 minutes is reasonable for most apps:
import rateLimit from 'express-rate-limit';
// Strict rate limit for login
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts
message: { error: 'Too many login attempts. Try again later.' },
standardHeaders: true,
skipSuccessfulRequests: true, // Don't count successful logins
});
app.post('/api/auth/login', loginLimiter, async (req, res) => {
// Login logic...
});
// Even stricter for password reset
const resetLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 3,
});
app.post('/api/auth/forgot-password', resetLimiter, async (req, res) => {
// Password reset logic...
});
Best Practice 4: Secure Password Requirements 10 min
Eight characters minimum, no common passwords. Don't gate on special characters alone. Length matters more:
import { z } from 'zod';
const passwordSchema = z.string()
.min(8, 'Password must be at least 8 characters')
.max(128, 'Password too long')
.refine(
(password) => /[A-Z]/.test(password),
'Must contain at least one uppercase letter'
)
.refine(
(password) => /[a-z]/.test(password),
'Must contain at least one lowercase letter'
)
.refine(
(password) => /[0-9]/.test(password),
'Must contain at least one number'
);
// Check against common passwords
import commonPasswords from './common-passwords.json';
const isCommonPassword = (password) => {
return commonPasswords.includes(password.toLowerCase());
};
function validatePassword(password) {
const result = passwordSchema.safeParse(password);
if (!result.success) {
return { valid: false, error: result.error.issues[0].message };
}
if (isCommonPassword(password)) {
return { valid: false, error: 'This password is too common' };
}
return { valid: true };
}
Best Practice 5: Secure Token Handling (JWT) 10 min
JWTs need short lifetimes and server-side revocation to be safe. The common mistake is a 30-day access token with no way to invalidate it:
import jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET; // Long random string
const ACCESS_TOKEN_EXPIRY = '15m'; // Short-lived
const REFRESH_TOKEN_EXPIRY = '7d'; // Longer-lived
// Create tokens
function generateTokens(userId) {
const accessToken = jwt.sign(
{ userId, type: 'access' },
JWT_SECRET,
{ expiresIn: ACCESS_TOKEN_EXPIRY }
);
const refreshToken = jwt.sign(
{ userId, type: 'refresh' },
JWT_SECRET,
{ expiresIn: REFRESH_TOKEN_EXPIRY }
);
return { accessToken, refreshToken };
}
// Verify token
function verifyToken(token, expectedType) {
try {
const decoded = jwt.verify(token, JWT_SECRET);
if (decoded.type !== expectedType) {
throw new Error('Invalid token type');
}
return decoded;
} catch (error) {
throw new Error('Invalid token');
}
}
// Store refresh tokens in database for revocation
async function storeRefreshToken(userId, token) {
await db.refreshToken.create({
data: { userId, token, expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) }
});
}
JWT Best Practices: Keep access token expiry short (15 minutes). Store refresh tokens server-side so you can revoke them. Use HttpOnly cookies instead of localStorage when you can.
Best Practice 6: Implement Logout Properly 5 min
Clearing the cookie isn't enough. You need to kill the session on the server too, or the old session token stays valid:
app.post('/api/auth/logout', authenticate, async (req, res) => {
// For sessions: destroy the session
req.session.destroy((err) => {
if (err) {
console.error('Session destruction error:', err);
}
});
// For JWTs with refresh tokens: revoke the refresh token
await db.refreshToken.deleteMany({
where: { userId: req.user.id }
});
// Clear cookies
res.clearCookie('sessionId', {
httpOnly: true,
secure: true,
sameSite: 'lax',
});
res.json({ success: true });
});
Best Practice 7: Account Recovery 15 min
Password reset is where most apps introduce subtle security bugs: user enumeration via different error messages, reset tokens stored in plain text, tokens that never expire. This pattern handles all three:
import crypto from 'crypto';
// Request password reset
app.post('/api/auth/forgot-password', async (req, res) => {
const { email } = req.body;
// Always return same response (prevent user enumeration)
res.json({ message: 'If an account exists, a reset link was sent.' });
const user = await db.user.findUnique({ where: { email } });
if (!user) return; // Silent fail
// Generate secure token
const token = crypto.randomBytes(32).toString('hex');
const expiry = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
// Hash token before storing (like a password)
const hashedToken = await bcrypt.hash(token, 10);
await db.passwordReset.create({
data: { userId: user.id, token: hashedToken, expiresAt: expiry }
});
// Send email with unhashed token
await sendEmail(email, `Reset link: .../reset?token=${token}`);
});
// Reset password
app.post('/api/auth/reset-password', async (req, res) => {
const { token, newPassword } = req.body;
// Find all non-expired reset requests
const resetRequests = await db.passwordReset.findMany({
where: { expiresAt: { gt: new Date() } },
include: { user: true },
});
// Check token against each (timing-safe)
let validRequest = null;
for (const request of resetRequests) {
if (await bcrypt.compare(token, request.token)) {
validRequest = request;
break;
}
}
if (!validRequest) {
return res.status(400).json({ error: 'Invalid or expired reset link' });
}
// Update password
const hashedPassword = await bcrypt.hash(newPassword, 12);
await db.user.update({
where: { id: validRequest.userId },
data: { password: hashedPassword },
});
// Delete all reset tokens and sessions for this user
await db.passwordReset.deleteMany({ where: { userId: validRequest.userId } });
await db.session.deleteMany({ where: { userId: validRequest.userId } });
res.json({ success: true });
});
Common Authentication Mistakes
| Mistake | Impact | Prevention |
|---|---|---|
| Plain text passwords | Mass credential theft | Always hash with bcrypt/Argon2 |
| User enumeration | Attackers learn valid emails | Same response for all cases |
| No rate limiting | Brute force attacks | Limit login attempts |
| Long JWT expiry | Token theft risk | Short-lived access tokens |
| Incomplete logout | Session persistence | Clear all tokens/sessions |
Official Resources: For comprehensive authentication guidance, see OWASP Authentication Cheat Sheet, OWASP Session Management Cheat Sheet, and OWASP Password Storage Cheat Sheet.
Should I use JWT or sessions?
Sessions are simpler and more secure for most web apps. JWTs work better for APIs accessed by multiple clients. For SPAs, consider sessions stored in HttpOnly cookies.
How long should sessions last?
For general apps: 24 hours to 7 days with activity-based renewal. For sensitive apps (banking, medical): shorter sessions (1-4 hours) with re-authentication for critical actions.
Should I require MFA for all users?
MFA significantly improves security. Consider requiring it for admin accounts and sensitive operations. For general users, strongly encourage it but balance with usability.
Is OAuth safer than password auth?
OAuth delegates auth to providers like Google who have dedicated security teams. It reduces password-related risks but adds dependency on the provider. Consider offering both options.
Further Reading
Verify Your Authentication Security
Scan your application for authentication vulnerabilities.