Authentication in Node¶
Auth is the most reimplemented , most broken thing in every Node app Everybody thinks they can roll their own auth system and everybody gets it wrong - timing attacks on comparison , storing plaintext passwords , JWT with alg: none Let's fix this shit once and for all
Password Hashing: bcrypt and argon2¶
If you're still using MD5 or SHA1 for passwords in 2026 , unfollow me
const bcrypt = require('bcrypt')
const SALT_ROUNDS = 12
async function register(email, password) {
const hash = await bcrypt.hash(password, SALT_ROUNDS)
return db.insert({ email, password_hash: hash })
}
async function login(email, password) {
const user = await db.findByEmail(email)
if (!user) return false
return bcrypt.compare(password, user.password_hash)
}
Argon2 is the modern choice - winner of the Password Hashing Competition
const argon2 = require('argon2')
async function hashPassword(password) {
return argon2.hash(password, {
type: argon2.argon2id, // hybrid resistant to side-channel + GPU
memoryCost: 65536, // 64 MB
timeCost: 3, // 3 iterations
parallelism: 2
})
}
async function verifyPassword(hash, password) {
try {
return await argon2.verify(hash, password)
} catch {
return false
}
}
Argon2id is the recommended variant : it's resistant to both side-channel and GPU attacks
JWT: Signing and Verification¶
const jwt = require('jsonwebtoken')
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET
function generateAccessToken(user) {
return jwt.sign(
{ userId: user.id, role: user.role },
ACCESS_SECRET,
{ expiresIn: '15m' } // short-lived
)
}
function generateRefreshToken(user) {
return jwt.sign(
{ userId: user.id, tokenVersion: user.tokenVersion },
REFRESH_SECRET,
{ expiresIn: '7d' }
)
}
// Auth middleware
function authenticate(req, res, next) {
const header = req.headers.authorization
if (!header || !header.startsWith('Bearer ')) {
return res.status(401).json({ error: 'no token provided' })
}
const token = header.split(' ')[1]
try {
const decoded = jwt.verify(token, ACCESS_SECRET, {
algorithms: ['HS256'] // enforce algorithm
})
req.user = decoded
next()
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'token expired' })
}
return res.status(401).json({ error: 'invalid token' })
}
}
Always enforce the algorithm in jwt.verify() : alg: none attacks work because developers forget to specify { algorithms: ['HS256'] }
Refresh Token Rotation¶
async function refreshAccessToken(refreshToken) {
try {
const decoded = jwt.verify(refreshToken, REFRESH_SECRET, {
algorithms: ['HS256']
})
// Rotate refresh token (invalidate old one)
const user = await User.findById(decoded.userId)
if (!user || user.tokenVersion !== decoded.tokenVersion) {
throw new Error('token revoked')
}
// Issue new pair
const newAccessToken = generateAccessToken(user)
const newRefreshToken = generateRefreshToken(user)
return { accessToken: newAccessToken, refreshToken: newRefreshToken }
} catch (err) {
return null
}
}
Token versioning lets you invalidate all tokens for a user : increment tokenVersion in the database when password changes or account compromise
Session-Based Auth¶
For server-rendered apps , sessions are still viable
const session = require('express-session')
const RedisStore = require('connect-redis')(session)
const { createClient } = require('redis')
const redisClient = createClient({ url: process.env.REDIS_URL })
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
name: 'sessionId', // not default 'connect.sid'
resave: false,
saveUninitialized: false, // don't create sessions for unauthenticated users
cookie: {
httpOnly: true, // no JS access
secure: true, // HTTPS only
sameSite: 'strict',// CSRF protection
maxAge: 24 * 60 * 60 * 1000 // 24h
}
}))
Session vs Token : sessions are simpler for server-rendered apps , tokens scale better for APIs and mobile clients Choose based on your architecture not hype
OAuth2 with Passport.js¶
const passport = require('passport')
const GoogleStrategy = require('passport-google-oauth20').Strategy
passport.use(new GoogleStrategy({
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: '/auth/google/callback'
}, async (accessToken, refreshToken, profile, done) => {
// Find or create user
let user = await User.findOne({ googleId: profile.id })
if (!user) {
user = await User.create({
googleId: profile.id,
email: profile.emails[0].value,
name: profile.displayName
})
}
done(null, user)
}))
// Routes
app.get('/auth/google',
passport.authenticate('google', {
scope: ['profile', 'email']
})
)
app.get('/auth/google/callback',
passport.authenticate('google', { session: true }),
(req, res) => {
res.redirect('/dashboard')
}
)
Never trust provider data blindly : verify the aud claim in the ID token matches your client ID
MFA Concepts¶
// Generate TOTP secret
const speakeasy = require('speakeasy')
const secret = speakeasy.generateSecret({ length: 20 })
// Store secret.base32 in user record
// Verify TOTP token
function verifyTOTP(userToken, userSecret) {
return speakeasy.totp.verify({
secret: userSecret,
encoding: 'base32',
token: userToken,
window: 1 // allow 30s clock drift
})
}
// Generate QR code for authenticator app
const qrcode = require('qrcode')
const otpauthUrl = speakeasy.otpauthURL({
secret: secret.base32,
label: 'MyApp:' + user.email,
issuer: 'MyApp'
})
const qrImage = await qrcode.toDataURL(otpauthUrl)
MFA is non-negotiable for admin accounts : if your admin panel doesn't require 2FA you're one password leak away from total compromise
Security Checklist¶
// Token storage - always httpOnly cookies
res.cookie('token', accessToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 15 * 60 * 1000
})
// CSRF protection
const csrf = require('csurf')
app.use(csrf({ cookie: true }))
// Rate limit login
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
skipSuccessfulRequests: true
})
// Account lockout after N failures
async function recordFailedLogin(email) {
const key = `login_attempts:${email}`
const attempts = await redis.incr(key)
await redis.expire(key, 900) // 15 min
return attempts >= 10
}
Prerequisites¶
- sec_03_input_validation.md - validate before you authenticate
next -> sec_05_helmet.md