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 setsecure: 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
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.
