Cybersecurity

How I Handle Authentication for 50K Users Without Losing My Mind

How I Handle Authentication for 50K Users Without Losing My Mind

Md. Rony Ahmed · 6 min read
How I Handle Authentication for 50K Users Without Losing My Mind

How I Handle Authentication for 50K Users Without Losing My Mind



Most auth tutorials stop at "it works." Mine stops at "it survives a real attack."




I used to think authentication was simple. You hash a password, sign a JWT, and call it a day.

Then I built a system that handles 50,000 active users. And I learned the hard way that "it works" and "it works at scale" are two completely different things.

This is the architecture I wish I'd found two years ago. No theory. Just the hybrid approach that survived a credential stuffing attack last month.




The Problem: JWT vs Sessions — Most Devs Pick Wrong



The internet loves JWTs. Stateless! Scalable! No database lookups!

What nobody tells you: JWTs are a liability at scale.

Approach → Works For → Dies At:

- Pure JWT → Single-page apps, short sessions → Dies at: Token revocation, password changes, logout
- Pure Sessions → Traditional web apps, monoliths → Dies at: Distributed systems, mobile apps
- Hybrid (what we use) → Everything → Dies at: Nothing — that's the point

Here's what happens when a user changes their password with pure JWTs:

1. Attacker has the old JWT (valid for 24 hours)
2. User changes password
3. Attacker keeps using the old JWT
4. You can't revoke it without maintaining a denylist

And now you're back to database lookups. Congratulations, you built sessions with extra steps.




What We Built: Short JWT + Rotated Refresh Tokens



Our hybrid approach:

┌─────────────┐     ┌──────────────────┐     ┌──────────────┐
│   Client    │────▶│   API Gateway    │────▶│  Auth Service│
│             │◀────│                  │◀────│              │
└─────────────┘     └──────────────────┘     └──────────────┘
        │                                           │
        │           Access Token (15 min)          │
        │◀──────────────────────────────────────────┤
        │                                           │
        │           Refresh Token (7 days)         │
        │◀──────────────────────────────────────────┤
        │                                           │
        │           Token Rotation on Use           │
        │─────────────────────────────────────────────▶│


The Rules



1. Access tokens expire in 15 minutes — short enough that stolen tokens are useless fast
2. Refresh tokens rotate on every use — old refresh token invalidated immediately
3. Refresh tokens are single-use — replay attacks die automatically
4. Token families track lineage — if a rotated token is used twice, we nuke the whole family
5. Sessions stored in Redis with TTL — we can revoke everything in <50ms




The Implementation (Node.js + Redis)



Token Generation



// auth/token.service.js
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const redis = require('../redis-client');

class TokenService {
  // Access token: short-lived, no sensitive data
  generateAccessToken(userId, roles) {
    return jwt.sign(
      { sub: userId, roles },
      process.env.ACCESS_TOKEN_SECRET,
      { expiresIn: '15m', issuer: 'codehustle' }
    );
  }

  // Refresh token: opaque, stored in Redis
  async generateRefreshToken(userId, tokenFamily) {
    const token = crypto.randomBytes(32).toString('hex');
    const key = `refresh:${token}`;
    
    await redis.setex(key, 7 * 24 * 3600, JSON.stringify({
      userId,
      family: tokenFamily || crypto.randomUUID(),
      createdAt: Date.now(),
      used: false
    }));
    
    return token;
  }

  // Rotation: invalidate old, create new
  async rotateRefreshToken(oldToken) {
    const key = `refresh:${oldToken}`;
    const data = await redis.get(key);
    
    if (!data) {
      // Token reused — possible theft
      const parsed = JSON.parse(data);
      await this.revokeTokenFamily(parsed.family);
      throw new Error('Token reuse detected');
    }
    
    const parsed = JSON.parse(data);
    
    if (parsed.used) {
      // Double-use detected — nuke the family
      await this.revokeTokenFamily(parsed.family);
      throw new Error('Token replay attack detected');
    }
    
    // Mark old token as used
    await redis.del(key);
    
    // Generate new refresh token in same family
    const newToken = await this.generateRefreshToken(
      parsed.userId, 
      parsed.family
    );
    
    return newToken;
  }

  async revokeTokenFamily(familyId) {
    // Pattern match all tokens in family
    const keys = await redis.keys(`refresh:*`);
    for (const key of keys) {
      const data = await redis.get(key);
      if (data) {
        const parsed = JSON.parse(data);
        if (parsed.family === familyId) {
          await redis.del(key);
        }
      }
    }
  }
}


Rate Limiting on Auth Endpoints



The attack vector most tutorials ignore:

// middleware/auth-rate-limit.js
const redis = require('../redis-client');

const authRateLimit = async (req, res, next) => {
  const ip = req.ip;
  const email = req.body.email;
  
  // Per-IP limit: 5 attempts per minute
  const ipKey = `ratelimit:ip:${ip}`;
  const ipAttempts = await redis.incr(ipKey);
  if (ipAttempts === 1) await redis.expire(ipKey, 60);
  
  if (ipAttempts > 5) {
    return res.status(429).json({
      error: 'Too many attempts. Try again in 1 minute.'
    });
  }
  
  // Per-email limit: 3 attempts per 15 minutes
  // This prevents distributed brute force
  const emailKey = `ratelimit:email:${email}`;
  const emailAttempts = await redis.incr(emailKey);
  if (emailAttempts === 1) await redis.expire(emailKey, 900);
  
  if (emailAttempts > 3) {
    // Log for security monitoring
    await logSecurityEvent('brute_force_attempt', { email, ip });
    return res.status(429).json({
      error: 'Account locked. Check your email.'
    });
  }
  
  next();
};





The Breach That Almost Happened



Last month at 3:47 AM, our monitoring fired:

ALERT: 847 login attempts for user rony@codehustle.tech
Source: 23 different IP addresses
Pattern: Credential stuffing attack


Here's what happened:

1. 847 attempts against one account in 4 minutes
2. 23 rotating IPs — clearly a botnet
3. Per-email rate limiting kicked in at attempt #4
4. Account locked — attacker couldn't proceed
5. Token rotation meant even if they'd succeeded, existing sessions were safe

Without our hybrid approach:
- Pure JWTs? Attacker gets a 24-hour token, changes password, still has access
- Pure sessions? Database lookups at 847 req/min = performance death
- Hybrid? 847 requests, 3 got through rate limit, 0 succeeded.




The Numbers



Metric — Before (Pure JWT) — After (Hybrid):

- Token revocation time — 24 hours (impossible) — <50ms
- DB lookups per auth check — 0 — 0 (Redis)
- Session hijack window — 24 hours — 15 minutes
- Password change safety — ❌ Old tokens valid — ✅ Instant revocation
- Brute force resistance — ❌ None — ✅ Per-email limiting




5-Minute Security Audit Checklist



Run this before your next deploy:

1. ✅ Token rotation works — log in, use refresh token twice, verify second attempt fails
2. ✅ Rate limiting on auth endpoints — hit /login 10 times fast, verify 429 response
3. ✅ Password change invalidates sessions — change password, verify old tokens die
4. ✅ Redis TTL matches token expiry — verify no orphaned tokens after 7 days
5. ✅ Alert on token reuse — set up monitoring for "Token replay attack detected"




The Real Lesson



Authentication isn't about picking JWT or sessions. It's about controlling session lifecycle.

Most tutorials show you how to sign a token. They don't show you what happens when:
- A user gets phished
- A password database leaks
- A nation-state targets your users

Build for the attack, not the tutorial.




Want the full production config? I share detailed implementation guides on the CodeHustle blog.

Built with Node.js, Redis, and the scars of 2 AM security incidents.