Skip to main content

Authentication in Node.js: JWT, Sessions, and OAuth

Published: May 24, 2026 Updated: August 29, 2026 Larry Qu 5 min read

Authentication verifies who a user is. The right mechanism depends on your architecture: JWT for stateless APIs and SPAs, sessions for traditional server-rendered apps, OAuth2 for “login with Google/GitHub/etc.” — and often a combination of these.

Password Hashing

Before any authentication system, passwords must be hashed with bcrypt. bcrypt is intentionally slow — each hash takes ~250ms at cost 12, making brute-force attacks impractical:

const bcrypt = require('bcrypt')

const BCRYPT_COST = 12  // increase over time as hardware gets faster

async function hashPassword(plaintext) {
  return bcrypt.hash(plaintext, BCRYPT_COST)
}

async function verifyPassword(plaintext, hash) {
  return bcrypt.compare(plaintext, hash)
}

// Registration endpoint
app.post('/auth/register', async (req, res) => {
  const { email, password, name } = req.body

  if (await User.findOne({ email })) {
    return res.status(409).json({ error: 'Email already registered' })
  }

  const hash = await hashPassword(password)
  const user = await User.create({ email, passwordHash: hash, name })
  res.status(201).json({ id: user.id, email: user.email })
})

Never log passwords, never store them in plain text, and always use bcrypt.compare (not ===) to check them — compare is constant-time, preventing timing attacks.

JWT: Stateless API Authentication

JWT (JSON Web Token) encodes claims (user ID, role, expiry) in a signed token. The server can verify the token without database lookups — ideal for stateless APIs and microservices.

The access/refresh token pattern balances security and usability: short-lived access tokens (15 minutes) limit exposure, while long-lived refresh tokens (7 days) avoid frequent re-logins:

const jwt = require('jsonwebtoken')

function issueTokens(user) {
  const accessToken = jwt.sign(
    { sub: user.id, email: user.email, role: user.role },
    process.env.JWT_SECRET,
    { expiresIn: '15m' }
  )

  const refreshToken = jwt.sign(
    { sub: user.id },
    process.env.JWT_REFRESH_SECRET,
    { expiresIn: '7d' }
  )

  return { accessToken, refreshToken }
}

// Login: verify credentials, issue tokens
app.post('/auth/login', async (req, res) => {
  const user = await User.findOne({ email: req.body.email })
  if (!user || !await bcrypt.compare(req.body.password, user.passwordHash)) {
    // Same message for both cases — don't reveal whether the email exists
    return res.status(401).json({ error: 'Invalid credentials' })
  }

  const tokens = issueTokens(user)

  // Store refresh token (hashed) so we can invalidate it later
  await RefreshToken.create({ userId: user.id, token: tokens.refreshToken })

  res.json(tokens)
})

// Refresh: exchange a refresh token for a new access token
app.post('/auth/refresh', async (req, res) => {
  const { refreshToken } = req.body
  if (!refreshToken) return res.status(401).json({ error: 'No refresh token' })

  try {
    const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET)

    // Verify it's in our database (allows revocation)
    const stored = await RefreshToken.findOne({ token: refreshToken, userId: payload.sub })
    if (!stored) return res.status(401).json({ error: 'Invalid refresh token' })

    const user = await User.findById(payload.sub)
    const newAccessToken = jwt.sign(
      { sub: user.id, email: user.email, role: user.role },
      process.env.JWT_SECRET,
      { expiresIn: '15m' }
    )

    res.json({ accessToken: newAccessToken })
  } catch {
    res.status(401).json({ error: 'Invalid or expired refresh token' })
  }
})

The authenticate middleware validates the access token on every protected request:

const authenticate = (req, res, next) => {
  const token = req.headers.authorization?.split(' ')[1]
  if (!token) return res.status(401).json({ error: 'Authentication required' })

  try {
    req.user = jwt.verify(token, process.env.JWT_SECRET)
    next()
  } catch {
    res.status(401).json({ error: 'Invalid or expired token' })
  }
}

// Usage
app.get('/api/profile', authenticate, (req, res) => {
  res.json({ user: req.user })
})

Session-Based Authentication

Sessions store state on the server. The browser holds a session ID cookie; the server looks up the session on each request. Better fit for server-rendered apps or when you need immediate session revocation:

const session = require('express-session')
const MongoStore = require('connect-mongo')

app.use(session({
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  store: MongoStore.create({ mongoUrl: process.env.MONGODB_URI }),
  cookie: {
    httpOnly: true,   // not accessible via document.cookie — prevents XSS token theft
    secure: process.env.NODE_ENV === 'production',  // HTTPS only in production
    sameSite: 'lax',  // CSRF protection — cookie not sent on cross-site requests
    maxAge: 24 * 60 * 60 * 1000,  // 24 hours
  },
}))

app.post('/auth/login', async (req, res) => {
  const user = await User.findOne({ email: req.body.email })
  if (!user || !await bcrypt.compare(req.body.password, user.passwordHash)) {
    return res.status(401).json({ error: 'Invalid credentials' })
  }

  // Store minimal data in session
  req.session.userId = user.id
  req.session.role = user.role

  res.json({ message: 'Logged in' })
})

const requireAuth = (req, res, next) => {
  if (!req.session.userId) {
    return res.status(401).json({ error: 'Not authenticated' })
  }
  next()
}

app.post('/auth/logout', (req, res) => {
  req.session.destroy(err => {
    if (err) return res.status(500).json({ error: 'Logout failed' })
    res.clearCookie('connect.sid')
    res.json({ message: 'Logged out' })
  })
})

The cookie security settings (httpOnly, secure, sameSite) are not optional in production — they prevent the most common session hijacking attacks.

OAuth2 with Passport.js

OAuth2 lets users log in with an existing account (Google, GitHub, etc.) without creating a new password. Passport.js handles the OAuth2 dance:

npm install passport passport-google-oauth20
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: `${process.env.APP_URL}/auth/google/callback`,
  },
  async (accessToken, refreshToken, profile, done) => {
    // Find or create user based on Google ID
    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,
        avatar: profile.photos[0]?.value,
      })
    }
    done(null, user)
  }
))

// Serialize/deserialize for session storage
passport.serializeUser((user, done) => done(null, user.id))
passport.deserializeUser(async (id, done) => {
  const user = await User.findById(id).catch(done)
  done(null, user)
})

app.use(passport.initialize())
app.use(passport.session())  // only needed if using sessions

// Routes
app.get('/auth/google',
  passport.authenticate('google', { scope: ['profile', 'email'] })
)

app.get('/auth/google/callback',
  passport.authenticate('google', { failureRedirect: '/login?error=oauth' }),
  (req, res) => {
    // Successful auth — issue JWT or redirect
    const tokens = issueTokens(req.user)
    res.redirect(`/auth/success?token=${tokens.accessToken}`)
  }
)

Choosing the Right Approach

JWT Sessions OAuth2
State Stateless Stateful (server) Depends
Revocation Only on expiry (unless blocklist) Immediate Provider-side
Best for APIs, SPAs, microservices Server-rendered apps “Login with…”
Cookie needed? Optional (can use header) Yes For the session

Most production apps combine approaches: JWT for API access, sessions for the web dashboard, and OAuth2 as one of the login methods.

Summary

  • bcrypt at cost 12 for password hashing — never SHA256/MD5 for passwords
  • JWT: short-lived access tokens (15m) + long-lived refresh tokens (7d) stored in your DB for revocation
  • Sessions: set httpOnly: true, secure: true (prod), sameSite: 'lax' on the cookie
  • OAuth2: Passport.js handles the complexity; you just define the “find or create user” logic
  • Return identical error messages for “wrong email” and “wrong password” — prevents user enumeration

Resources

Comments

👍 Was this article helpful?