Authentication Interview Questions: JWT, Sessions, OAuth & Node.js Security

Sławomir Plamowski 15 min read
authentication backend interview-questions jwt nodejs oauth security

"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 tokenVersion field. 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:

  1. Strong authentication: Password + MFA (TOTP or hardware key)
  2. Short sessions: 15-minute inactivity timeout, 8-hour max
  3. Session binding: Tie sessions to IP/device fingerprint
  4. Step-up auth: Re-authenticate for sensitive operations (transfers)
  5. Audit logging: Log all auth events, monitor for anomalies
  6. Rate limiting: Aggressive limits on failed attempts
  7. 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

  1. Storing passwords in plain text - Always use bcrypt or Argon2
  2. JWT in localStorage - XSS can steal it; use httpOnly cookies
  3. Long-lived access tokens - Keep them short (15 minutes)
  4. Same error for "user not found" vs "wrong password" - Reveals user existence
  5. No rate limiting on login - Enables brute force attacks
  6. Accepting algorithm 'none' - Always specify allowed algorithms
  7. Weak secrets - Use 256+ bit secrets from secure random
  8. Session fixation - Regenerate session ID on login
  9. No CSRF protection with cookies - Use sameSite and CSRF tokens
  10. Logging passwords or tokens - Sensitive data in logs

Related Articles

If you found this helpful, check out these related guides:

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.

Ready for More Interview Questions?

This is just one topic from our complete interview prep guide. Get access to 800+ questions across 13 technologies.

Get Full Access Try Free Preview
Back to blog

Leave a comment

Please note, comments need to be approved before they are published.