Skip to main content

Command Palette

Search for a command to run...

3 Authentication Bugs That Exposed User Data

Learn: 3 Authentication Bugs That Exposed User Data

Updated
12 min readView as Markdown
T

Welcome to TopperBlog! 👋

I'm a tech content creator passionate about helping developers level up their careers and master cutting-edge technologies.

🎯 What I Write About: • AI/ML Engineering & LLMs • Web3 & Blockchain Development
• System Design & Architecture • Interview Preparation (FAANG) • Freelancing & Remote Work • Modern Tech Stacks (Next.js, React, Rust, TypeScript) • Performance Optimization & Best Practices

💼 Mission: Sharing practical, actionable insights that accelerate your tech career and maximize your earning potential.

📚 15+ In-Depth Guides covering everything from earning $10k/month as a freelancer to cracking FAANG interviews.

🌐 Let's connect and grow together in this amazing tech journey!

#TechBlogger #SoftwareEngineering #CareerGrowth #WebDevelopment #AIEngineering

3 Authentication Bugs That Exposed User Data

Security lessons learned the hard way

The 3 AM Wake-Up Call Nobody Wants

Picture this: It's 3:17 AM on a Tuesday, and my phone won't stop buzzing. Half-asleep, I grab it expecting some spam about extended car warranties. Instead, it's our security monitoring system screaming that someone in Romania just accessed 47 user accounts in under two minutes.

My heart rate went from "peaceful slumber" to "caffeinated squirrel" in about three seconds.

That night kicked off the worst week of my career as a backend engineer. But it also taught me more about authentication security than any textbook ever could. Today, I'm sharing three authentication bugs that exposed user data in our system—and more importantly, how we fixed them so you don't have to learn these lessons at 3 AM.

The Story: When "It Works on My Machine" Meets Reality

Six months into my role at a growing SaaS startup, I was feeling pretty confident. We'd just shipped a major feature update, user signups were climbing, and my code reviews were getting those sweet approval emojis. Life was good.

Our authentication system seemed solid: JWT tokens, password hashing with bcrypt, the works. We'd even passed a basic security audit. What could go wrong?

Turns out, a lot.

The Romanian incident was just the beginning. Over the next 72 hours, we discovered three critical authentication vulnerabilities that had been lurking in our codebase like digital landmines. Each one could have (and briefly did) expose sensitive user data. The scariest part? These weren't exotic zero-day exploits. They were embarrassingly common mistakes that I'd somehow convinced myself we were too smart to make.

Spoiler alert: We weren't.

Technical Deep Dive: Three Bugs That Kept Me Up at Night

Problem Breakdown: The Unholy Trinity of Auth Failures

Let me walk you through each vulnerability, what made it dangerous, and the exact code that saved our bacon.

Bug #1: The JWT Token That Never Dies

Our first problem was a classic case of "set it and forget it" gone wrong. We were issuing JWT tokens with no expiration time. None. Zilch. These tokens were basically immortal digital keys to user accounts.

The attack vector was simple: An attacker got hold of a single valid token (through a phishing attack on one user), and suddenly had permanent access to that account. Even after the user changed their password, that old token kept working like nothing happened.

Bug #2: The Case-Sensitive Email Catastrophe

This one makes me cringe even now. Our login system treated emails as case-sensitive, but our registration system didn't. So john@example.com and John@example.com were treated as different users during login, but the same user during registration.

An attacker could register John@example.com, then use password reset flows intended for john@example.com to gain access to the original account. It was like having two keys to the same apartment, but the landlord only knew about one of them.

Bug #3: The Session Fixation Fiasco

We were generating session IDs before authentication completed. This meant an attacker could force a victim to use a session ID the attacker already knew, then wait for the victim to log in. Once authenticated, the attacker could hijack the session using that pre-set ID.

It's like handing someone a hotel room key before they check in, then sneaking into their room after they've unlocked it.

Solution 1: Implementing Proper JWT Expiration and Refresh Tokens

The fix for our immortal tokens required implementing a proper token lifecycle with both access tokens and refresh tokens.

// Before: The dangerous immortal token
const jwt = require('jsonwebtoken');

function generateToken(user) {
  return jwt.sign(
    { userId: user.id, email: user.email },
    process.env.JWT_SECRET
    // No expiration - DANGER!
  );
}

// After: Short-lived access token with refresh mechanism
function generateAccessToken(user) {
  return jwt.sign(
    { userId: user.id, email: user.email },
    process.env.JWT_SECRET,
    { expiresIn: '15m' } // Expires in 15 minutes
  );
}

function generateRefreshToken(user) {
  const refreshToken = jwt.sign(
    { userId: user.id, tokenVersion: user.tokenVersion },
    process.env.REFRESH_TOKEN_SECRET,
    { expiresIn: '7d' } // Expires in 7 days
  );

  // Store refresh token hash in database
  storeRefreshToken(user.id, hashToken(refreshToken));
  return refreshToken;
}

// Token refresh endpoint
app.post('/auth/refresh', async (req, res) => {
  const { refreshToken } = req.body;

  try {
    const decoded = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET);
    const user = await User.findById(decoded.userId);

    // Verify token version (allows invalidation on password change)
    if (decoded.tokenVersion !== user.tokenVersion) {
      throw new Error('Token version mismatch');
    }

    // Verify refresh token exists in database
    const isValid = await verifyRefreshToken(user.id, hashToken(refreshToken));
    if (!isValid) {
      throw new Error('Invalid refresh token');
    }

    // Issue new access token
    const newAccessToken = generateAccessToken(user);
    res.json({ accessToken: newAccessToken });

  } catch (error) {
    res.status(401).json({ error: 'Invalid refresh token' });
  }
});

// Invalidate all tokens on password change
async function changePassword(userId, newPassword) {
  await User.findByIdAndUpdate(userId, {
    password: await bcrypt.hash(newPassword, 12),
    tokenVersion: user.tokenVersion + 1 // Invalidates all existing tokens
  });

  // Clear all refresh tokens
  await clearRefreshTokens(userId);
}

The key improvements here:

  • Access tokens expire quickly (15 minutes), limiting the damage window
  • Refresh tokens are stored server-side, allowing us to revoke them
  • Token versioning ensures password changes invalidate all existing sessions
  • Refresh tokens are hashed before storage (defense in depth)

Solution 2: Normalizing Email Addresses and Preventing Session Fixation

For the email case-sensitivity bug, we needed to normalize emails consistently across our entire authentication flow.

// Email normalization utility
function normalizeEmail(email) {
  if (!email || typeof email !== 'string') {
    throw new Error('Invalid email');
  }

  // Convert to lowercase and trim whitespace
  const normalized = email.toLowerCase().trim();

  // Basic email validation
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  if (!emailRegex.test(normalized)) {
    throw new Error('Invalid email format');
  }

  return normalized;
}

// Registration endpoint
app.post('/auth/register', async (req, res) => {
  try {
    const email = normalizeEmail(req.body.email);

    // Check if user already exists
    const existingUser = await User.findOne({ email });
    if (existingUser) {
      return res.status(400).json({ error: 'Email already registered' });
    }

    const hashedPassword = await bcrypt.hash(req.body.password, 12);
    const user = await User.create({
      email, // Already normalized
      password: hashedPassword
    });

    res.status(201).json({ message: 'User created successfully' });
  } catch (error) {
    res.status(400).json({ error: error.message });
  }
});

// Login endpoint
app.post('/auth/login', async (req, res) => {
  try {
    const email = normalizeEmail(req.body.email);
    const user = await User.findOne({ email });

    if (!user || !(await bcrypt.compare(req.body.password, user.password))) {
      // Generic error message to prevent user enumeration
      return res.status(401).json({ error: 'Invalid credentials' });
    }

    const accessToken = generateAccessToken(user);
    const refreshToken = generateRefreshToken(user);

    res.json({ accessToken, refreshToken });
  } catch (error) {
    res.status(400).json({ error: error.message });
  }
});

For the session fixation vulnerability, we needed to regenerate session IDs after successful authentication:

// Before: Session ID generated before authentication
app.post('/auth/login', (req, res) => {
  // Session already exists from previous request
  const user = authenticateUser(req.body.email, req.body.password);
  if (user) {
    req.session.userId = user.id; // Using existing session ID - DANGER!
    res.json({ success: true });
  }
});

// After: Regenerate session ID after authentication
app.post('/auth/login', async (req, res) => {
  try {
    const email = normalizeEmail(req.body.email);
    const user = await User.findOne({ email });

    if (!user || !(await bcrypt.compare(req.body.password, user.password))) {
      return res.status(401).json({ error: 'Invalid credentials' });
    }

    // Critical: Regenerate session ID after successful authentication
    req.session.regenerate((err) => {
      if (err) {
        return res.status(500).json({ error: 'Session error' });
      }

      // Now set user data in the NEW session
      req.session.userId = user.id;
      req.session.email = user.email;

      // Set secure session cookie options
      res.cookie('sessionId', req.sessionID, {
        httpOnly: true,  // Prevents JavaScript access
        secure: true,    // HTTPS only
        sameSite: 'strict', // CSRF protection
        maxAge: 3600000  // 1 hour
      });

      res.json({ success: true });
    });

  } catch (error) {
    res.status(500).json({ error: 'Authentication failed' });
  }
});

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

Solution 3: Adding Comprehensive Security Middleware

Beyond fixing individual bugs, we implemented defense-in-depth with security middleware:

const rateLimit = require('express-rate-limit');
const helmet = require('helmet');

// Rate limiting for authentication endpoints
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 5, // 5 requests per window
  message: 'Too many authentication attempts, please try again later',
  standardHeaders: true,
  legacyHeaders: false,
});

// Apply security headers
app.use(helmet());

// Rate limit auth endpoints
app.use('/auth/login', authLimiter);
app.use('/auth/register', authLimiter);
app.use('/auth/password-reset', authLimiter);

// Token validation middleware
function authenticateToken(req, res, next) {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN

  if (!token) {
    return res.status(401).json({ error: 'Access token required' });
  }

  jwt.verify(token, process.env.JWT_SECRET, (err, decoded) => {
    if (err) {
      if (err.name === 'TokenExpiredError') {
        return res.status(401).json({ error: 'Token expired' });
      }
      return res.status(403).json({ error: 'Invalid token' });
    }

    req.user = decoded;
    next();
  });
}

// Audit logging for security events
async function logSecurityEvent(eventType, userId, metadata) {
  await SecurityLog.create({
    eventType,
    userId,
    timestamp: new Date(),
    ipAddress: metadata.ip,
    userAgent: metadata.userAgent,
    success: metadata.success
  });
}

// Enhanced login with logging
app.post('/auth/login', authLimiter, async (req, res) => {
  const metadata = {
    ip: req.ip,
    userAgent: req.get('user-agent'),
    success: false
  };

  try {
    const email = normalizeEmail(req.body.email);
    const user = await User.findOne({ email });

    if (!user || !(await bcrypt.compare(req.body.password, user.password))) {
      await logSecurityEvent('LOGIN_FAILED', null, metadata);
      return res.status(401).json({ error: 'Invalid credentials' });
    }

    metadata.success = true;
    await logSecurityEvent('LOGIN_SUCCESS', user.id, metadata);

    req.session.regenerate((err) => {
      if (err) {
        return res.status(500).json({ error: 'Session error' });
      }

      req.session.userId = user.id;
      const accessToken = generateAccessToken(user);
      const refreshToken = generateRefreshToken(user);

      res.json({ accessToken, refreshToken });
    });

  } catch (error) {
    await logSecurityEvent('LOGIN_ERROR', null, metadata);
    res.status(500).json({ error: 'Authentication failed' });
  }
});

Quick Comparison Table

Security AspectBefore (Vulnerable)After (Secured)
Token ExpirationNo expiration (immortal tokens)15-minute access tokens + 7-day refresh tokens
Token RevocationImpossible to revoke tokensToken versioning + server-side refresh token storage
Email HandlingCase-sensitive (inconsistent)Normalized to lowercase everywhere
Session SecuritySession ID reused after loginSession regenerated after authentication
Rate LimitingNone5 attempts per 15 minutes on auth endpoints
Security LoggingBasic application logsComprehensive audit trail with IP and user agent
Cookie SecurityDefault settingshttpOnly, secure, sameSite flags enabled
Password ChangesOld tokens still validAll tokens invalidated immediately

Key Takeaways

Here's what I learned from this painful experience (so you don't have to):

  • Always set token expiration times – Immortal tokens are immortal security risks. Use short-lived access tokens (15-30 minutes) with longer refresh tokens (7-30 days).

  • Normalize user input consistently – Email addresses, usernames, and any identifier should be normalized the same way everywhere in your codebase. Inconsistency creates vulnerabilities.

  • Regenerate session IDs after authentication – This simple step prevents session fixation attacks. Most frameworks make this easy—use it.

  • Implement token versioning – When users change passwords or request security actions, you need a way to invalidate all existing tokens. Token versioning is your friend.

  • Rate limit authentication endpoints – Brute force attacks are real. Five failed attempts in 15 minutes is generous enough for legitimate users and restrictive enough to slow attackers.

  • Log security events comprehensively – You can't investigate what you didn't log. Track login attempts, token refreshes, password changes, and failures with IP addresses and timestamps.

  • Use defense in depth – No single security measure is perfect. Layer multiple protections: token expiration + rate limiting + audit logging + secure cookies.

  • Test your authentication flow thoroughly – Don't just test the happy path. Try to break it. What happens with expired tokens? Invalid sessions? Case variations in emails?

  • Keep secrets actually secret – Use environment variables for JWT secrets, never commit them to version control, and rotate them periodically.

  • Monitor for anomalies – Set up alerts for unusual patterns: multiple failed logins, token refreshes from different IPs, rapid account access patterns.

FAQ

Q: How long should JWT access tokens last?

A: For most applications, 15-30 minutes is the sweet spot. Short enough to limit damage if compromised, long enough that users aren't constantly refreshing. High-security applications (banking, healthcare) might go as low as 5 minutes. The key is pairing short-lived access tokens with longer refresh tokens (7-30 days) so users don't have to re-authenticate constantly.

Q: Should I store JWT tokens in localStorage or cookies?

A: Cookies with httpOnly, secure, and sameSite flags are generally more secure than localStorage. localStorage is accessible to JavaScript, making it vulnerable to XSS attacks. HttpOnly cookies can't be accessed by JavaScript, providing better protection. However, cookies require CSRF protection, which you can implement with sameSite=strict or CSRF tokens.

Q: What's the difference between session-based and token-based authentication?

A: Session-based authentication stores user state on the server (in memory or database), while token-based authentication stores state in the token itself (like JWT). Sessions are easier to revoke but require server-side storage and don't scale as easily across multiple servers. Tokens are stateless and scale better but are harder to revoke (hence the need for refresh tokens and token versioning). Many modern apps use a hybrid approach: JWT for access tokens with server-side refresh token storage.

Q: How do I handle authentication in a microservices architecture?

A: Use a centralized authentication service that issues JWT tokens. Other services validate these tokens without calling back to the auth service (using the JWT signature). Store the public key for JWT verification in each service. For token revocation, implement a token blacklist or use short-lived tokens with a centralized refresh token service. Consider using an API gateway to handle token validation before requests reach your services.

Q: What should I do if I discover an authentication vulnerability in production?

A: First, don't panic (easier said than done at 3 AM). Assess the scope: how many users are affected, what data is exposed, how long has it been vulnerable. Fix the vulnerability immediately and deploy. Force password resets for affected accounts if necessary. Invalidate all existing tokens if the vulnerability involves token handling. Notify affected users if data was actually accessed (check your logs). Document everything for post-mortem analysis. And yes, you might need to report it depending on your jurisdiction and industry regulations.

Q: How often should I rotate JWT secrets?

A: For JWT signing secrets, rotate every 3-6 months as a baseline, or immediately if you suspect compromise. When rotating, implement a grace period where both old and new secrets are valid for token verification (but only the new secret is used for signing). This prevents breaking active sessions during rotation. Store multiple valid secrets with timestamps, and gradually phase out old ones after all tokens signed with them have expired.

Conclusion: Security Is a Journey, Not a Destination

That 3 AM wake-up call was one of the worst moments of my career, but it taught me something valuable: security isn't about being perfect—it's about being prepared, vigilant, and humble enough to admit when you've screwed up.

We fixed our authentication bugs, implemented better monitoring, and haven't had a similar incident since. But I still check our security logs more often than I probably should. Call it PTSD or call it wisdom—either way, it keeps our users safer.

The truth is, every developer will eventually ship a security bug. The difference between a minor incident and a catastrophic breach often comes down to how quickly you detect and respond to problems. Implement proper token expiration, normalize your inputs, regenerate sessions after authentication, and for the love of all that is holy, set up monitoring and alerts.

Your future self (and your users) will thank you. Preferably at a more reasonable hour than 3:17 AM.

Now if you'll excuse me, I need to go check our security logs one more time. Old habits die hard.