Auth in Express¶
Express doesn't ship with auth. You bolt it on. Get it wrong and users get pwned
Authentication tells you who the user is. Authorization tells you what they can do. Don't confuse them. Every route needs the right check. Every token needs proper validation. Every password needs proper hashing. Slip on any of these and your security model is theater
Passport.js strategies¶
Passport is the most popular Express auth middleware. It uses "strategies" - pluggable auth modules
npm install passport
# plus strategy-specific packages
npm install passport-local # username/password
npm install passport-jwt # JWT tokens
npm install passport-oauth2 # OAuth 2.0 (Google , GitHub , etc)
const passport = require('passport')
app.use(passport.initialize())
// if using sessions:
app.use(passport.session())
Passport Local Strategy¶
const LocalStrategy = require('passport-local').Strategy
const bcrypt = require('bcrypt')
passport.use(new LocalStrategy(
async (username , password , done) => {
try {
const user = await db.findUserByEmail(username)
if (!user) {
return done(null , false , { message: 'Invalid credentials' })
}
const isValid = await bcrypt.compare(password , user.passwordHash)
if (!isValid) {
return done(null , false , { message: 'Invalid credentials' })
}
return done(null , user)
} catch (err) {
return done(err)
}
}
))
// session serialization
passport.serializeUser((user , done) => done(null , user.id))
passport.deserializeUser(async (id , done) => {
const user = await db.findUserById(id)
done(null , user)
})
// route
app.post('/login' , passport.authenticate('local' , {
successRedirect: '/',
failureRedirect: '/login',
failureFlash: true
}))
bcrypt - password hashing¶
Never store plaintext passwords. Never use MD5 or SHA for passwords. bcrypt or die
npm install bcrypt
const bcrypt = require('bcrypt')
const SALT_ROUNDS = 12
// hashing
async function hashPassword(plaintext) {
return bcrypt.hash(plaintext , SALT_ROUNDS)
}
// comparison
async function checkPassword(plaintext , hash) {
return bcrypt.compare(plaintext , hash)
}
// register route
app.post('/register' , async (req , res) => {
const passwordHash = await hashPassword(req.body.password)
const user = await db.createUser({
email: req.body.email,
passwordHash
})
res.status(201).json({ created: true })
})
Salt rounds: 10-12 is standard. Higher = slower for both you AND attackers. 12 is fine in 2026
JWT - sign and verify¶
JSON Web Tokens for stateless auth:
npm install jsonwebtoken
const jwt = require('jsonwebtoken')
// signing
function generateToken(user) {
return jwt.sign(
{
userId: user.id,
role: user.role
},
process.env.JWT_SECRET,
{ expiresIn: '1h' }
)
}
// verification middleware
const authenticateJWT = (req , res , next) => {
const authHeader = req.headers.authorization
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' })
}
const token = authHeader.split(' ')[1]
try {
const decoded = jwt.verify(token , process.env.JWT_SECRET)
req.user = decoded
next()
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' })
}
return res.status(403).json({ error: 'Invalid token' })
}
}
// protected route
app.get('/profile' , authenticateJWT , (req , res) => {
res.json({ user: req.user })
})
JWT best practices¶
// 1. DON'T include sensitive data in JWT payload
// JWT payloads are base64 encoded NOT encrypted
// Anyone can read: Buffer.from(token.split('.')[1], 'base64')
// BAD: jwt.sign({ password: 'secret123' , ssn: '123-45-6789' } , secret)
// GOOD: jwt.sign({ userId: 42 , role: 'user' } , secret)
// 2. ALWAYS set expiration
// BAD: jwt.sign({ userId: 42 } , secret) // never expires
// GOOD: jwt.sign({ userId: 42 } , secret , { expiresIn: '15m' })
// 3. Use short-lived access tokens + long-lived refresh tokens
const ACCESS_TOKEN_EXPIRY = '15m'
const REFRESH_TOKEN_EXPIRY = '7d'
// 4. Rotate secrets periodically
// Store JWT_SECRET in env , update during maintenance windows
OAuth with Passport¶
npm install passport-google-oauth20
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 from Google profile
let user = await db.findUserByGoogleId(profile.id)
if (!user) {
user = await db.createUser({
googleId: profile.id,
email: profile.emails[0].value,
name: profile.displayName
})
}
return done(null , user)
}
))
app.get('/auth/google',
passport.authenticate('google' , { scope: ['profile' , 'email'] })
)
app.get('/auth/google/callback',
passport.authenticate('google' , { failureRedirect: '/login' }),
(req , res) => {
res.redirect('/')
}
)
protected routes pattern¶
// middleware/authorize.js
const authorize = (...allowedRoles) => {
return (req , res , next) => {
if (!req.user) {
return res.status(401).json({ error: 'Not authenticated' })
}
if (!allowedRoles.includes(req.user.role)) {
return res.status(403).json({ error: 'Insufficient permissions' })
}
next()
}
}
// usage
const { authenticateJWT } = require('../middleware/auth')
const { authorize } = require('../middleware/authorize')
// anyone authenticated
app.get('/profile' , authenticateJWT , profileHandler)
// only admins
app.delete('/users/:id' , authenticateJWT , authorize('admin') , deleteUserHandler)
// multiple roles
app.post('/reports' , authenticateJWT , authorize('admin' , 'manager') , reportHandler)
prerequisites¶
express_10_sessions.md - session stores , regeneration , security
next → express_12_security.md