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
Comments