Back to Blog

Is Your Website Leaking Users? Fixing Critical Auth & Login Flaws

Discover critical authentication testing issues that cause user churn. Learn how to fix login security flaws, improve user authentication, and secure your login process.

ScanlyApp Team

QA Testing and Automation Experts

Published

14 min read

Reading time

Is Your Website Leaking Users? Fixing Critical Auth & Login Flaws

Your analytics show 1,000 users created accounts yesterday. Only 127 successfully logged in today. Where did the other 873 go?

They didn't leave because they lost interest—they left because your authentication system failed them. A slow login page, a confusing password reset flow, an unexpected logout, or worse—a security vulnerability that exposed their data.

Authentication testing is often treated as an afterthought, but it's the gateway to your entire application. Get login security wrong, and users never experience your amazing features. Get user authentication right, and you build trust that keeps customers for years.

In this comprehensive guide, we'll expose the most common web security flaws in authentication systems and show you exactly how tofix them. Whether you're using OAuth, session-based auth, or JWT tokens, these patterns apply universally.

The True Cost of Authentication Failures

Before diving into specific flaws, understand what's at stake:

User Impact

Authentication Issue User Experience Abandonment Rate
Login page loads >3s Frustration, impatience 32%
Password reset doesn't work Locked out, angry 89%
Unexpected session expiry Data loss, distrust 67%
"Wrong password" (when correct) Confusion, abandonment 71%
Account locked after failed attempts Legitimate users blocked 54%

Business Impact

Real costs:

  • User acquisition cost wasted: Spent $50-200 to acquire users who can't log in
  • Support burden: 40-60% of support tickets relate to authentication
  • Lost revenue: Users who can't authenticate can't convert
  • Security liability: Breaches due to auth flaws cost $4.24M average
  • Reputation damage: One security incident affects brand for years

The statistics are brutal: 23% of users abandon purchases due to forgotten passwords, and 92% will leave a site rather than recover or reset a password.

Flaw #1: Insecure Password Storage

The Problem

Storing passwords in plain text or using weak hashing is catastrophic:

// ❌ NEVER DO THIS
const user = {
  email: 'user@example.com',
  password: 'SecureP@ssw0rd', // Plain text—disaster waiting to happen
};
await database.users.insert(user);

Why it's terrible: When (not if) your database is compromised, attackers instantly have all user credentials. They'll try these passwords on every other service (credential stuffing attacks).

The Fix: Proper Password Hashing

Use bcrypt, scrypt, or argon2 with appropriate cost factors:

// ✅ Secure password storage
const bcrypt = require('bcrypt');

async function hashPassword(password) {
  const saltRounds = 12; // Adjust based on server performance
  return await bcrypt.hash(password, saltRounds);
}

async function verifyPassword(password, hashedPassword) {
  return await bcrypt.compare(password, hashedPassword);
}

// Registration
const hashedPassword = await hashPassword(req.body.password);
await database.users.insert({
  email: req.body.email,
  password: hashedPassword, // Stored securely
});

// Login verification
const user = await database.users.findOne({ email: req.body.email });
const isValid = await verifyPassword(req.body.password, user.password);
if (isValid) {
  // Proceed with login
}

Password Security Checklist

✅ Use bcrypt, scrypt, or argon2 (never MD5, SHA1)
✅ Set appropriate cost factor (bcrypt: 12+, argon2: adjust memory)
✅ Never save passwords in logs
✅ Implement rate limiting on login attempts
✅ Consider using a password manager-friendly interface
✅ Require minimum password strength (8+ characters, mixed case, numbers)

Flaw #2: Vulnerable Session Management

The Problem

Poorly implemented sessions let attackers hijack accounts:

Common mistakes:

  • Session tokens predictable
  • Sessions never expire
  • Sessions not invalidated on logout
  • Session fixation vulnerabilities
  • Session ID in URL (exposedthrough referrer headers)
// ❌ Insecure session implementation
app.post('/login', (req, res) => {
  const user = authenticateUser(req.body);
  if (user) {
    // Predictable session ID—terrible!
    const sessionId = `${user.id}-${Date.now()}`;
    res.cookie('session', sessionId);
    res.redirect('/dashboard');
  }
});

The Fix: Secure Session Implementation

// ✅ Secure session management
const session = require('express-session');
const RedisStore = require('connect-redis')(session);

app.use(
  session({
    store: new RedisStore({ client: redisClient }),
    secret: process.env.SESSION_SECRET, // Strong random secret
    name: 'sessionId', // Don't use default 'connect.sid'
    resave: false,
    saveUninitialized: false,
    cookie: {
      secure: true, // HTTPS only
      httpOnly: true, // Not accessible via JavaScript
      maxAge: 24 * 60 * 60 * 1000, // 24 hours
      sameSite: 'strict', // CSRF protection
    },
  }),
);

// Login with proper session creation
app.post('/login', async (req, res) => {
  const user = await authenticateUser(req.body);
  if (user) {
    // Regenerate session ID to prevent fixation
    req.session.regenerate((err) => {
      if (err) return res.status(500).send('Session error');

      req.session.userId = user.id;
      req.session.loginTime = Date.now();

      res.redirect('/dashboard');
    });
  }
});

// Logout with complete session destruction
app.post('/logout', (req, res) => {
  req.session.destroy((err) => {
    res.clearCookie('sessionId');
    res.redirect('/login');
  });
});

Session Security Checklist

✅ Generate cryptographically random session IDs
✅ Regenerate session ID on login
✅ Set secure, httpOnly, sameSite cookie flags
✅ Implement session timeout (idle + absolute)
✅ Destroy sessions completely on logout
✅ Store sessions server-side (Redis, database)
✅ Never pass session IDs in URLs

Flaw #3: Missing or Weak CSRF Protection

The Problem

Cross-Site Request Forgery attacks trick logged-in users into performing unwanted actions:

<!-- Attacker's malicious site -->
<img src="https://yourbank.com/transfer?to=attacker&amount=10000" />

If the user is logged into yourbank.com, this request succeeds using their session.

The Fix: CSRF Token Implementation

const csrf = require('csurf');
const csrfProtection = csrf({ cookie: true });

// Generate CSRF token for forms
app.get('/settings', csrfProtection, (req, res) => {
  res.render('settings', {
    csrfToken: req.csrfToken(),
  });
});

// Validate CSRF token on submission
app.post('/settings', csrfProtection, (req, res) => {
  // If we reach here, CSRF token was valid
  updateUserSettings(req.body);
  res.send('Settings updated');
});
<!-- Include CSRF token in forms -->
<form method="POST" action="/settings">
  <input type="hidden" name="_csrf" value="{{ csrfToken }}" />
  <!-- form fields -->
  <button type="submit">Save</button>
</form>

Modern Alternative: SameSite Cookies

res.cookie('session', sessionId, {
  sameSite: 'strict', // Blocks cross-site requests
  secure: true,
  httpOnly: true,
});

sameSite options:

  • strict: Maximum protection (blocks all cross-site requests)
  • lax: Reasonable protection (allows top-level navigation)
  • none: No protection (must also set secure: true)

Flaw #4: Broken Password Reset Flows

The Problem

Password reset is a backdoor that attackers exploit:

Common vulnerabilities:

  • Reset tokens predictable
  • Tokens never expire
  • Tokens don't invalidate after use
  • Tokens sent over insecure channels
  • User enumeration via reset responses
// ❌ Insecure password reset
app.post('/forgot-password', async (req, res) => {
  const user = await findUserByEmail(req.body.email);
  if (user) {
    // Predictable token—terrible!
    const token = Buffer.from(user.email).toString('base64');
    await sendEmail(user.email, `Reset link: /reset?token=${token}`);
    res.send('Reset link sent');
  } else {
    res.send('Email not found'); // Reveals account existence!
  }
});

The Fix: Secure Password Reset

const crypto = require('crypto');

app.post('/forgot-password', async (req, res) => {
  const email = req.body.email;
  const user = await findUserByEmail(email);

  // Always show same message (prevent enumeration)
  const message = 'If an account exists, you will receive a reset email';

  if (user) {
    // Generate cryptographically secure token
    const token = crypto.randomBytes(32).toString('hex');
    const hashedToken = crypto.createHash('sha256').update(token).digest('hex');

    // Store hashed token with expiration
    await database.passwordResets.insert({
      userId: user.id,
      token: hashedToken,
      expiresAt: new Date(Date.now() + 60 * 60 * 1000), // 1 hour
    });

    // Send email with token (not hashed version)
    await sendEmail(user.email, {
      subject: 'Password Reset Request',
      body: `Reset your password: https://example.com/reset-password?token=${token}`,
    });
  }

  res.send(message); // Same response regardless
});

app.post('/reset-password', async (req, res) => {
  const { token, newPassword } = req.body;
  const hashedToken = crypto.createHash('sha256').update(token).digest('hex');

  // Find valid, unexpired token
  const resetRecord = await database.passwordResets.findOne({
    token: hashedToken,
    expiresAt: { $gt: new Date() },
    used: false,
  });

  if (!resetRecord) {
    return res.status(400).send('Invalid or expired reset token');
  }

  // Update password
  const hashedPassword = await bcrypt.hash(newPassword, 12);
  await database.users.update({ id: resetRecord.userId }, { password: hashedPassword });

  // Mark token as used
  await database.passwordResets.update({ id: resetRecord.id }, { used: true });

  // Invalidate all sessions for this user
  await invalidateAllUserSessions(resetRecord.userId);

  res.send('Password updated successfully');
});

Password Reset Security Checklist

✅ Generate cryptographically random tokens
✅ Hash tokens before storing
✅ Set short expiration (30-60 minutes)
✅ Mark tokens as used after password reset
✅ Prevent user enumeration (same response for valid/invalid emails)
✅ Invalidate all sessions after password change
✅ Rate limit reset requests
✅ Log all password reset attempts

Flaw #5: Insufficient Rate Limiting

The Problem

Without rate limiting, attackers can:

  • Brute-force passwords
  • Enumerate valid usernames
  • Overwhelm your authentication service (DoS)
  • Bypass weak account lockout mechanisms

The Fix: Multi-Layer Rate Limiting

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

// Global rate limit (all requests)
const globalLimiter = rateLimit({
  store: new RedisStore({ client: redisClient }),
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // 100 requests per window
  message: 'Too many requests from this IP',
});

// Strict rate limit for auth endpoints
const authLimiter = rateLimit({
  store: new RedisStore({ client: redisClient }),
  windowMs: 15 * 60 * 1000,
  max: 5, // Only 5 login attempts per 15 minutes
  skipSuccessfulRequests: true, // Don't count successful logins
  message: 'Too many login attempts. Please try again later.',
});

app.use(globalLimiter);
app.post('/login', authLimiter, async (req, res) => {
  // Login logic
});

// Additional per-account rate limiting
async function checkAccountLockout(email) {
  const attempts = await redis.get(`login_attempts:${email}`);
  if (attempts && parseInt(attempts) >= 10) {
    const lockoutExpiry = await redis.ttl(`login_attempts:${email}`);
    throw new Error(`Account temporarily locked. Try again in ${lockoutExpiry} seconds`);
  }
}

async function recordFailedLogin(email) {
  const key = `login_attempts:${email}`;
  await redis.incr(key);
  await redis.expire(key, 30 * 60); // 30-minute window
}

Rate Limiting Strategy

Endpoint Window Limit Action on Exceed
/login 15 min 5 attempts Temporary block
/forgot-password 1 hour 3 requests Block, notify user
/register 1 hour 3 accounts Require CAPTCHA
/reset-password 1 day 5 attempts Invalidate token

Flaw #6: Weak Multi-Factor Authentication (MFA)

The Problem

MFA implemented poorly provides false security:

Common issues:

  • SMS 2FA (vulnerable to SIM swapping)
  • Backup codes stored insecurely
  • MFA can be bypassed
  • No recovery mechanism
  • Users not required to enable MFA

The Fix: Robust MFA Implementation

const speakeasy = require('speakeasy');
const QRCode = require('qrcode');

// MFA enrollment
app.post('/enable-mfa', async (req, res) => {
  // Generate secret
  const secret = speakeasy.generateSecret({
    name: `YourApp (${req.user.email})`
  });

  // Store temporary secret (not yet activated)
  await database.users.update(
    { id: req.user.id },
    { mfaSecretTemp: secret.base32 }
  );

  // Generate QR code for authenticator app
  const qrCode = await QRCode.toDataURL(secret.otpauth_url);

  res.json({
    secret: secret.base32,
    qrCode: qrCode
  });
});

// Verify and activate MFA
app.post('/verify-mfa-setup', async (req, res) => {
  const user = await database.users.findOne({ id: req.user.id });

  const verified = speakeasy.totp.verify({
    secret: user.mfaSecretTemp,
    encoding: 'base32',
    token: req.body.code,
    window: 2  // Allow 2 time steps before/after
  });

  if (verified) {
    // Generate backup codes
    const backupCodes = generateBackupCodes(10);
    const hashedCodes = backupCodes.map(code => bcrypt.hashSync(code, 10));

    // Activate MFA
    await database.users.update(
      { id: req.user.id },
      {
        mfaEnabled: true,
        mfaSecret: user.mfaSecretTemp,
        mfaSecretTemp: null,
        mfaBackupCodes: hashedCodes
      }
    });

    res.json({
      success: true,
      backupCodes: backupCodes  // Show once, user must save
    });
  } else {
    res.status(400).send('Invalid code');
  }
});

// Login with MFA
app.post('/login', async (req, res) => {
  const user = await authenticateUser(req.body);
  if (!user) return res.status(401).send('Invalid credentials');

  if (user.mfaEnabled) {
    // Require MFA verification
    req.session.pendingMfaUserId = user.id;
    res.json({ requiresMfa: true });
  } else {
    // Complete login
    completeLogin(req, res, user);
  }
});

app.post('/verify-mfa', async (req, res) => {
  const userId = req.session.pendingMfaUserId;
  if (!userId) return res.status(400).send('No pending MFA verification');

  const user = await database.users.findOne({ id: userId });

  // Verify TOTP code
  const verified = speakeasy.totp.verify({
    secret: user.mfaSecret,
    encoding: 'base32',
    token: req.body.code,
    window: 2
  });

  if (verified) {
    delete req.session.pendingMfaUserId;
    completeLogin(req, res, user);
  } else {
    res.status(401).send('Invalid MFA code');
  }
});

MFA Best Practices

✅ Support authenticator apps (TOTP), not just SMS
✅ Generate and securely store backup codes
✅ Enforce MFA for admin/privileged accounts
✅ Allow users to manage trusted devices
✅ Provide clear recovery process
✅ Log all MFA events
✅ Consider WebAuthn/FIDO2 for passwordless

Flaw #7: Authentication State Confusion

The Problem

Application doesn't consistently validate authentication state:

Scenarios:

  • API endpoints don't check authentication
  • Client-side route protection only
  • Inconsistent auth checks across microservices
  • Privilege escalation through tampered tokens
// ❌ Insecure—client-side only protection
// Attacker can bypass by calling API directly
<Route path="/admin" element={<AdminPanel />} />;

// API endpoint missing auth check
app.get('/api/admin/users', async (req, res) => {
  const users = await database.users.find();
  res.json(users); // Exposed!
});

The Fix: Defense in Depth

// ✅ Auth middleware validates every request
function requireAuth(req, res, next) {
  if (!req.session || !req.session.userId) {
    return res.status(401).send('Authentication required');
  }
  next();
}

function requireRole(role) {
  return async (req, res, next) => {
    const user = await database.users.findOne({ id: req.session.userId });
    if (!user || user.role !== role) {
      return res.status(403).send('Insufficient permissions');
    }
    req.user = user;
    next();
  };
}

// Apply middleware to protected routes
app.get('/api/profile', requireAuth, async (req, res) => {
  const user = await database.users.findOne({ id: req.session.userId });
  res.json(user);
});

app.get('/api/admin/users', requireAuth, requireRole('admin'), async (req, res) => {
  const users = await database.users.find();
  res.json(users);
});

// Client-side routing still protected (UX)
function ProtectedRoute({ children, requiredRole }) {
  const { user, loading } = useAuth();

  if (loading) return <Loading />;
  if (!user) return <Navigate to="/login" />;
  if (requiredRole && user.role !== requiredRole) {
    return <Navigate to="/unauthorized" />;
  }

  return children;
}

Automated Authentication Testing

Manual security review misses issues. Automate testing:

// Authentication test suite
describe('Authentication Security', () => {
  test('rejects login with invalid credentials', async () => {
    const response = await request(app).post('/login').send({ email: 'user@example.com', password: 'wrong' });

    expect(response.status).toBe(401);
  });

  test('requires authentication for protected routes', async () => {
    const response = await request(app).get('/api/profile');
    expect(response.status).toBe(401);
  });

  test('destroys session on logout', async () => {
    // Login
    const login = await request(app).post('/login').send({ email: 'user@example.com', password: 'correct' });

    const cookies = login.headers['set-cookie'];

    // Logout
    await request(app).post('/logout').set('Cookie', cookies);

    // Try to access protected route with old session
    const response = await request(app).get('/api/profile').set('Cookie', cookies);

    expect(response.status).toBe(401);
  });

  test('regenerates session ID on login', async () => {
    const response1 = await request(app).get('/');
    const initialSession = response1.headers['set-cookie'][0];

    const response2 = await request(app)
      .post('/login')
      .set('Cookie', initialSession)
      .send({ email: 'user@example.com', password: 'correct' });

    const newSession = response2.headers['set-cookie'][0];

    expect(newSession).not.toBe(initialSession);
  });
});

Connecting Authentication to Broader Security

Authentication testing is foundational to web security, but it's just one layer. Implementing continuous testing in CI/CD ensures auth changes don't introduce vulnerabilities. Understanding common website bugs helps catch security issues before deployment.

Stop Leaking Users

You now understand the critical login security flaws that cost businesses millions in lost users and expose them to devastating breaches. You know how to implement secure user authentication, protect sessions, prevent CSRF attacks, and build robust password reset flows.

Authentication is your application's front door—make it secure, reliable, and user-friendly.

Automated Authentication Testing with ScanlyApp

ScanlyApp continuously validates your authentication implementation with comprehensive authentication testing that catches security flaws before attackers do:

Login Flow Validation – Automatic testing of authentication endpoints
Session Management Checks – Verify proper session handling
Security Boundary Testing – Ensure authorization works correctly
CSRF Protection Validation – Confirm anti-CSRF measures functional
Password Reset Testing – Validate reset flows work securely
MFA Validation – Test multi-factor authentication logic

Start Your Free Trial →

Catch login security flaws before users encounter them. Get comprehensive authentication testing running in 2 minutes.

Related articles: Also see an in-depth security audit of your OAuth2 and OIDC flows, mapping your auth flaws to the OWASP Top 10 vulnerability framework, and automating MFA testing to close the authentication loop.


Need help securing your authentication system? Talk to our security experts—we're here to help you build bulletproof login flows.

Related Posts