TL;DR
The #1 authentication security best practice is using established auth libraries instead of building your own. These 7 practices take about 60 minutes to implement and prevent 91% of authentication-related breaches. Focus on: hashing passwords with bcrypt or Argon2, storing sessions securely, implementing rate limiting on login, and adding 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
Authentication is complex. Use battle-tested solutions:
| 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 |
Do not build custom auth unless you have specific expertise. Custom auth implementations are the source of most authentication vulnerabilities.
Best Practice 1: Hash Passwords Correctly 5 min
Never store plain text passwords. Use a proper hashing algorithm:
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
Sessions need proper configuration to be secure:
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
Prevent brute force attacks on login:
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
Enforce reasonable password policies:
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
If using JWTs, follow these guidelines:
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: Use short expiry for access tokens (15 minutes), store refresh tokens server-side for revocation, use HttpOnly cookies instead of localStorage when possible.
Best Practice 6: Implement Logout Properly 5 min
Logout should invalidate the session completely:
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 flows need careful security:
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.
Verify Your Authentication Security
Scan your application for authentication vulnerabilities.
Start Free Scan