Skip to content

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

next -> sec_05_helmet.md