Skip to content

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