"How would you implement authentication?" This question appears in virtually every backend interview, yet it catches candidates off guard. The answer isn't just "use JWT" or "use Passport.js"—interviewers want to see that you understand the tradeoffs between different approaches and can choose the right one for specific requirements. Here's how to demonstrate that understanding.
The 30-Second Answer
When the interviewer asks "How would you implement authentication?", here's your concise answer:
"It depends on the use case. For a traditional web app, I'd use session-based auth with cookies—it's simple and supports immediate revocation. For an API serving mobile apps or SPAs, I'd use JWT with short-lived access tokens and refresh tokens. For 'Login with Google' features, I'd use OAuth 2.0. In Node.js, Passport.js handles all these strategies, but I'd also consider managed services like Auth0 for complex requirements like MFA."
That's it. Wait for follow-up questions.
The 2-Minute Answer (If They Want More)
If they ask you to elaborate:
"The two main approaches are stateful sessions and stateless tokens.
Sessions store user state on the server. The client gets a session ID in a cookie, and the server looks up the session on each request. Pros: easy revocation, small cookie size, server controls everything. Cons: requires server-side storage, harder to scale horizontally, doesn't work well across domains.
JWT (JSON Web Tokens) are self-contained tokens with encoded user data, signed by the server. The client sends the token with each request, and the server validates the signature without database lookup. Pros: stateless, scales horizontally, works across domains. Cons: can't revoke until expiry, larger payload, token theft is harder to detect.
OAuth 2.0 is for delegated authorization—letting users grant your app access to their data on other services like Google or GitHub. It's not authentication by itself, though OpenID Connect adds that layer.
In practice, I often combine approaches: sessions for web UI, JWT for API access, OAuth for social login. The key is matching the auth strategy to the specific security requirements and architecture constraints."
Authentication vs Authorization
Get this distinction right—interviewers often start here:
Authentication: WHO are you?
├── Verify identity
├── Check credentials (password, token, biometric)
└── Result: "This is user #123"
Authorization: WHAT can you do?
├── Check permissions
├── Evaluate roles/policies
└── Result: "User #123 can edit this resource"
// Express middleware example
const authenticate = async (req, res, next) => {
// WHO is this?
const token = req.cookies.token;
const user = await verifyToken(token);
if (!user) return res.status(401).json({ error: 'Not authenticated' });
req.user = user;
next();
};
const authorize = (...roles) => (req, res, next) => {
// WHAT can they do?
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Not authorized' });
}
next();
};
// Usage
app.delete('/users/:id',
authenticate, // Must be logged in
authorize('admin'), // Must be admin
deleteUser
);Interview insight: "Authentication answers 'who are you?' while authorization answers 'what can you do?' A 401 means 'I don't know who you are' while 403 means 'I know who you are, but you're not allowed.'"
Session-Based Authentication
The traditional approach, still widely used for server-rendered web apps.
How It Works
1. User submits credentials
2. Server validates, creates session in database/Redis
3. Server sends session ID in httpOnly cookie
4. Browser sends cookie with every request
5. Server looks up session, attaches user to request
Implementation
const express = require('express');
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const redis = require('redis');
const bcrypt = require('bcrypt');
const redisClient = redis.createClient();
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production', // HTTPS only
httpOnly: true, // No JavaScript access
maxAge: 24 * 60 * 60 * 1000, // 24 hours
sameSite: 'strict' // CSRF protection
}
}));
// Login
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = await User.findOne({ email });
if (!user) {
// Don't reveal if email exists
return res.status(401).json({ error: 'Invalid credentials' });
}
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Regenerate session to prevent fixation
req.session.regenerate((err) => {
req.session.userId = user.id;
req.session.role = user.role;
res.json({ message: 'Logged in' });
});
});
// Logout
app.post('/logout', (req, res) => {
req.session.destroy((err) => {
res.clearCookie('connect.sid');
res.json({ message: 'Logged out' });
});
});
// Auth middleware
const requireAuth = (req, res, next) => {
if (!req.session.userId) {
return res.status(401).json({ error: 'Not authenticated' });
}
next();
};Session Pros and Cons
| Pros | Cons |
|---|---|
| Immediate revocation (delete session) | Requires server-side storage |
| Small cookie size (~32 bytes) | Harder to scale (need shared session store) |
| Server has full control | Doesn't work across different domains |
| Simple to implement | CSRF protection needed |
JWT (JSON Web Tokens)
Stateless tokens for APIs, microservices, and cross-domain authentication.
JWT Structure
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4iLCJpYXQiOjE1MTYyMzkwMjJ9.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
[Header].[Payload].[Signature]
// Header (algorithm + type)
{
"alg": "HS256",
"typ": "JWT"
}
// Payload (claims)
{
"sub": "user123", // Subject (user ID)
"name": "John Doe",
"role": "admin",
"iat": 1516239022, // Issued at
"exp": 1516242622 // Expiration
}
// Signature
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)Implementation
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET;
const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET;
const ACCESS_TOKEN_EXPIRY = '15m';
const REFRESH_TOKEN_EXPIRY = '7d';
// Generate tokens
const generateTokens = (user) => {
const accessToken = jwt.sign(
{ userId: user.id, role: user.role },
ACCESS_TOKEN_SECRET,
{ expiresIn: ACCESS_TOKEN_EXPIRY }
);
const refreshToken = jwt.sign(
{ userId: user.id, tokenVersion: user.tokenVersion },
REFRESH_TOKEN_SECRET,
{ expiresIn: REFRESH_TOKEN_EXPIRY }
);
return { accessToken, refreshToken };
};
// Login
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = await User.findOne({ email });
if (!user || !await bcrypt.compare(password, user.passwordHash)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const { accessToken, refreshToken } = generateTokens(user);
// Store refresh token in httpOnly cookie
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
});
res.json({ accessToken });
});
// Refresh token endpoint
app.post('/refresh', async (req, res) => {
const { refreshToken } = req.cookies;
if (!refreshToken) {
return res.status(401).json({ error: 'No refresh token' });
}
try {
const payload = jwt.verify(refreshToken, REFRESH_TOKEN_SECRET);
const user = await User.findById(payload.userId);
// Check token version (for revocation)
if (!user || user.tokenVersion !== payload.tokenVersion) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
const tokens = generateTokens(user);
// Rotate refresh token
res.cookie('refreshToken', tokens.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000
});
res.json({ accessToken: tokens.accessToken });
} catch (error) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
});
// Auth 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 payload = jwt.verify(token, ACCESS_TOKEN_SECRET);
req.user = payload;
next();
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
return res.status(401).json({ error: 'Invalid token' });
}
};
// Logout (revoke all refresh tokens for user)
app.post('/logout', authenticateJWT, async (req, res) => {
// Increment token version to invalidate all refresh tokens
await User.findByIdAndUpdate(req.user.userId, {
$inc: { tokenVersion: 1 }
});
res.clearCookie('refreshToken');
res.json({ message: 'Logged out' });
});JWT Pros and Cons
| Pros | Cons |
|---|---|
| Stateless - no server storage | Can't revoke until expiry (without blacklist) |
| Scales horizontally easily | Larger than session cookies |
| Works across domains/services | Token theft harder to detect |
| Contains user data (fewer DB queries) | Sensitive data exposure if not encrypted |
JWT Security Pitfalls
// BAD: Storing in localStorage (XSS vulnerable)
localStorage.setItem('token', accessToken);
// GOOD: httpOnly cookie for refresh token
res.cookie('refreshToken', token, { httpOnly: true });
// BAD: Using 'none' algorithm
jwt.verify(token, secret, { algorithms: ['none', 'HS256'] });
// GOOD: Specify allowed algorithms
jwt.verify(token, secret, { algorithms: ['HS256'] });
// BAD: Weak secret
const secret = 'secret123';
// GOOD: Strong secret (256 bits for HS256)
const secret = crypto.randomBytes(32).toString('hex');
// BAD: Long expiration
{ expiresIn: '30d' }
// GOOD: Short access token, longer refresh token
accessToken: { expiresIn: '15m' }
refreshToken: { expiresIn: '7d' }OAuth 2.0
Delegated authorization for third-party access.
OAuth 2.0 Flows
Authorization Code Flow (Web Apps):
┌──────────┐ ┌───────────────┐ ┌─────────────┐
│ User │────>│ Your App │────>│ Google │
│ Browser │ │ (Backend) │ │ Auth Server │
└──────────┘ └───────────────┘ └─────────────┘
│ │ │
│ 1. Click "Login with Google" │
│─────────────────>│ │
│ │ 2. Redirect to Google
│ │────────────────────>│
│ 3. User consents │
│<───────────────────────────────────────│
│ 4. Redirect with auth code │
│─────────────────>│ │
│ │ 5. Exchange code for tokens
│ │────────────────────>│
│ │ 6. Access + ID tokens
│ │<────────────────────│
│ 7. Create session │
│<─────────────────│ │
Implementation 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) => {
try {
// 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,
avatar: profile.photos[0]?.value
});
}
return done(null, user);
} catch (error) {
return done(error, null);
}
}
));
// Serialize user to session
passport.serializeUser((user, done) => {
done(null, user.id);
});
passport.deserializeUser(async (id, done) => {
const user = await User.findById(id);
done(null, user);
});
// Routes
app.get('/auth/google',
passport.authenticate('google', { scope: ['profile', 'email'] })
);
app.get('/auth/google/callback',
passport.authenticate('google', { failureRedirect: '/login' }),
(req, res) => {
res.redirect('/dashboard');
}
);OAuth 2.0 Grant Types
| Grant Type | Use Case | Security Level |
|---|---|---|
| Authorization Code | Web apps with backend | High |
| Authorization Code + PKCE | SPAs, mobile apps | High |
| Client Credentials | Server-to-server | High |
| Implicit (deprecated) | Legacy SPAs | Low - don't use |
| Password (deprecated) | Trusted first-party | Low - avoid |
Interview insight: "I'd use Authorization Code flow for web apps because the client secret stays on the server. For mobile apps or SPAs, I'd add PKCE to prevent authorization code interception attacks."
Password Security
Passwords require special handling—this is heavily tested in interviews.
const bcrypt = require('bcrypt');
const crypto = require('crypto');
// Hashing passwords
const SALT_ROUNDS = 12; // Adjustable work factor
const hashPassword = async (password) => {
return bcrypt.hash(password, SALT_ROUNDS);
};
const verifyPassword = async (password, hash) => {
return bcrypt.compare(password, hash);
};
// Password requirements
const validatePassword = (password) => {
const errors = [];
if (password.length < 8) errors.push('At least 8 characters');
if (!/[A-Z]/.test(password)) errors.push('At least one uppercase letter');
if (!/[a-z]/.test(password)) errors.push('At least one lowercase letter');
if (!/[0-9]/.test(password)) errors.push('At least one number');
// Check against common passwords
const common = ['password', '12345678', 'qwerty'];
if (common.includes(password.toLowerCase())) {
errors.push('Password too common');
}
return errors;
};
// Secure password reset
const generateResetToken = async (user) => {
const token = crypto.randomBytes(32).toString('hex');
const hashedToken = crypto.createHash('sha256').update(token).digest('hex');
user.resetToken = hashedToken;
user.resetTokenExpiry = Date.now() + 3600000; // 1 hour
await user.save();
return token; // Send this to user, store hashed version
};
const verifyResetToken = async (token) => {
const hashedToken = crypto.createHash('sha256').update(token).digest('hex');
const user = await User.findOne({
resetToken: hashedToken,
resetTokenExpiry: { $gt: Date.now() }
});
return user;
};Key principles:
- Never store plain text passwords
- Use bcrypt (or Argon2) with sufficient work factor
- Hash reset tokens before storing
- Expire reset tokens quickly (1 hour max)
- Rate limit password attempts
Session vs JWT Decision Framework
| Requirement | Session | JWT |
|---|---|---|
| Server-rendered web app | Yes | Possible |
| REST API for mobile | Possible | Yes |
| Microservices | Complex | Yes |
| Immediate revocation needed | Yes | With blacklist |
| Cross-domain auth | No | Yes |
| Horizontal scaling | With Redis | Yes |
| Sensitive data in token | N/A | No |
Interview answer: "For a traditional web app, I'd start with sessions—they're simpler and support immediate revocation. For an API serving mobile apps, JWT makes more sense because it's stateless and works across domains. In practice, I often use both: sessions for the web interface, and JWT for API access."
Common Interview Questions
"How would you implement 'Remember Me'?"
"I'd use a longer-lived refresh token stored in an httpOnly cookie. When the user checks 'remember me', I'd set the refresh token expiry to 30 days instead of the default 7 days. The access token stays short-lived (15 minutes) for security. On each visit, if the access token is expired but the refresh token is valid, I'd silently refresh the access token."
"How do you handle logout with JWT?"
"Since JWTs can't be invalidated before expiry, I'd use a token version or blacklist approach. Each user has a
tokenVersionfield. When they log out, I increment it. The refresh token includes the version at issue time—if it doesn't match the current version, it's rejected. For immediate access token invalidation, I'd use a short-lived blacklist in Redis that expires when the token would have."
"What happens if a JWT secret is compromised?"
"That's a critical incident. I'd immediately rotate the secret and deploy. All existing tokens would become invalid, forcing users to re-authenticate. To minimize impact, I'd use different secrets for access vs refresh tokens, so compromising one doesn't compromise both. Long-term, I'd consider asymmetric keys (RS256) where only the private key needs protecting."
"Design authentication for a banking app"
"I'd implement defense in depth:
- Strong authentication: Password + MFA (TOTP or hardware key)
- Short sessions: 15-minute inactivity timeout, 8-hour max
- Session binding: Tie sessions to IP/device fingerprint
- Step-up auth: Re-authenticate for sensitive operations (transfers)
- Audit logging: Log all auth events, monitor for anomalies
- Rate limiting: Aggressive limits on failed attempts
- Secure channels: HTTPS only, HSTS, certificate pinning for mobile"
Multi-Factor Authentication (MFA)
const speakeasy = require('speakeasy');
const QRCode = require('qrcode');
// Setup MFA
app.post('/mfa/setup', authenticateJWT, async (req, res) => {
const secret = speakeasy.generateSecret({
name: `MyApp:${req.user.email}`
});
// Store secret temporarily until verified
await User.findByIdAndUpdate(req.user.userId, {
mfaSecretTemp: secret.base32
});
const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url);
res.json({ qrCode: qrCodeUrl, secret: secret.base32 });
});
// Verify and enable MFA
app.post('/mfa/verify', authenticateJWT, async (req, res) => {
const { token } = req.body;
const user = await User.findById(req.user.userId);
const verified = speakeasy.totp.verify({
secret: user.mfaSecretTemp,
encoding: 'base32',
token,
window: 1 // Allow 1 period tolerance
});
if (!verified) {
return res.status(400).json({ error: 'Invalid code' });
}
// Enable MFA
user.mfaSecret = user.mfaSecretTemp;
user.mfaEnabled = true;
user.mfaSecretTemp = undefined;
await user.save();
res.json({ message: 'MFA enabled' });
});
// Login with MFA
app.post('/login', async (req, res) => {
const { email, password, mfaToken } = req.body;
const user = await User.findOne({ email }).select('+passwordHash +mfaSecret');
if (!user || !await bcrypt.compare(password, user.passwordHash)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
if (user.mfaEnabled) {
if (!mfaToken) {
return res.status(200).json({ requiresMfa: true });
}
const verified = speakeasy.totp.verify({
secret: user.mfaSecret,
encoding: 'base32',
token: mfaToken,
window: 1
});
if (!verified) {
return res.status(401).json({ error: 'Invalid MFA code' });
}
}
// Issue tokens
const tokens = generateTokens(user);
res.json({ accessToken: tokens.accessToken });
});Rate Limiting
Essential for preventing brute-force attacks:
const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
// General API rate limiting
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100,
message: { error: 'Too many requests, try again later' }
});
// Strict limiting for auth endpoints
const authLimiter = rateLimit({
store: new RedisStore({ client: redisClient }),
windowMs: 15 * 60 * 1000,
max: 5, // 5 attempts per 15 minutes
skipSuccessfulRequests: true, // Only count failures
keyGenerator: (req) => req.body.email || req.ip,
message: { error: 'Too many login attempts, try again in 15 minutes' }
});
app.use('/api/', apiLimiter);
app.use('/login', authLimiter);
app.use('/register', authLimiter);Quick Reference
| Concept | Implementation | Security Note |
|---|---|---|
| Password storage | bcrypt with 12+ rounds | Never plain text |
| Session ID | Random 128-bit, httpOnly cookie | Regenerate on login |
| Access token | JWT, 15min expiry | Never in localStorage |
| Refresh token | Opaque or JWT, httpOnly cookie | Rotate on use |
| Password reset | Random token, 1h expiry, single use | Hash before storing |
| MFA | TOTP (Google Auth) or WebAuthn | Require for sensitive ops |
Common Mistakes to Avoid
- Storing passwords in plain text - Always use bcrypt or Argon2
- JWT in localStorage - XSS can steal it; use httpOnly cookies
- Long-lived access tokens - Keep them short (15 minutes)
- Same error for "user not found" vs "wrong password" - Reveals user existence
- No rate limiting on login - Enables brute force attacks
- Accepting algorithm 'none' - Always specify allowed algorithms
- Weak secrets - Use 256+ bit secrets from secure random
- Session fixation - Regenerate session ID on login
- No CSRF protection with cookies - Use sameSite and CSRF tokens
- Logging passwords or tokens - Sensitive data in logs
Related Articles
If you found this helpful, check out these related guides:
- Complete Node.js Backend Developer Interview Guide - comprehensive preparation guide for backend interviews
- WebSockets & Socket.IO Interview Guide - Authenticating WebSocket connections
- Web Security & OWASP Interview Guide - Security vulnerabilities and prevention
- Express.js Middleware Interview Guide - Middleware patterns for auth implementation
- REST API Interview Guide - API design including authentication
Ready for More Security Interview Questions?
This is just one topic from our complete backend interview prep guide. Get access to 50+ questions covering:
- OAuth 2.0 deep dive and OpenID Connect
- Session management patterns
- API security best practices
- Encryption and key management
- Security architecture for microservices
Get Full Access to All Backend Questions
Written by the EasyInterview team, based on real interview experience from 12+ years in tech and hundreds of technical interviews conducted at companies like BNY Mellon, UBS, and leading fintech firms.
