Password Spraying with Jwt Tokens
How Password Spraying Manifests in JWT Tokens
Password spraying attacks targeting JWT tokens exploit the stateless nature of token-based authentication systems. Unlike traditional session-based authentication where server-side state can detect unusual patterns, JWT tokens present unique vulnerabilities when improperly implemented.
The most common JWT password spraying pattern involves attackers systematically trying common passwords across many accounts, but with a JWT-specific twist. Since JWT tokens are cryptographically signed, attackers can identify valid token generation endpoints by observing response differences. When an invalid password is submitted, the server typically returns a generic error message, but the response time or headers might leak information about whether the username exists.
Consider this vulnerable JWT implementation:
app.post('/login', async (req, res) => {
const { username, password } = req.body;
const user = await db.getUser(username);
// Timing attack vulnerability
if (!user || !await comparePasswords(password, user.password)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = jwt.sign(
{ sub: user.id, username: user.username },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
res.json({ token });
});An attacker can exploit this by measuring response times. Valid usernames with incorrect passwords might take slightly longer due to password hashing operations, while non-existent usernames return immediately. This timing difference enables username enumeration, a critical precursor to password spraying.
Another JWT-specific vulnerability occurs when refresh tokens are implemented without proper rate limiting. Attackers can request new access tokens using refresh tokens at high frequency:
// Vulnerable refresh endpoint
app.post('/refresh', (req, res) => {
const { refreshToken } = req.body;
// No rate limiting, no IP tracking
const decoded = jwt.verify(refreshToken, process.env.REFRESH_SECRET);
const user = db.getUserById(decoded.sub);
const newToken = jwt.sign(
{ sub: user.id, username: user.username },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
res.json({ token: newToken });
});This allows attackers to rotate tokens rapidly while attempting different password combinations, making detection harder since each request appears legitimate with valid tokens.
Multi-tenant JWT implementations face additional risks. When JWT claims include tenant identifiers without proper validation, attackers can target specific tenants by manipulating the 'tenant' claim:
// Vulnerable multi-tenant validation
const token = jwt.verify(req.headers.authorization, process.env.JWT_SECRET);
const tenant = token.tenant; // No validation that user belongs to this tenant
const resources = await db.getResourcesByTenant(tenant);Attackers can modify the tenant claim to access resources across different organizations, combining this with password spraying to maximize impact.
JWT-Specific Detection
Detecting password spraying in JWT token systems requires analyzing both authentication patterns and token lifecycle behaviors. Traditional detection methods need JWT-specific adaptations.
Response timing analysis is crucial for JWT systems. Use tools like tcptrace or custom middleware to measure and log response times for authentication endpoints. Look for statistically significant timing differences between valid and invalid usernames:
// Detection middleware
app.use('/login', (req, res, next) => {
const startTime = Date.now();
const originalSend = res.send;
res.send = function(data) {
const duration = Date.now() - startTime;
const username = req.body.username;
// Log timing with username
if (duration < 50) {
logger.warn(`Fast response for ${username}: ${duration}ms`);
}
originalSend.call(this, data);
};
next();
});Monitor JWT token issuance patterns using centralized logging. Track the frequency of token generations per IP address, user agent, and time window:
// Centralized token monitoring
const tokenAnalytics = new Map();
function trackTokenGeneration(username, ip) {
const key = `${ip}:${username}`;
const record = tokenAnalytics.get(key) || { count: 0, first: Date.now(), last: Date.now() };
record.count++;
record.last = Date.now();
tokenAnalytics.set(key, record);
// Alert if threshold exceeded
if (record.count > 10) {
const rate = record.count / ((record.last - record.first) / 1000);
if (rate > 5) { // More than 5 attempts per second
alertManager.sendAlert({
type: 'password_spray',
target: key,
attempts: record.count,
rate: rate.toFixed(2)
});
}
}
}Analyze JWT claims for anomalies. Implement claim validation middleware that checks for suspicious patterns:
function validateJwtClaims(token) {
const decoded = jwt.decode(token, { complete: true });
const now = Date.now() / 1000;
// Check for future issued-at times
if (decoded.payload.iat > now + 60) {
return { valid: false, reason: 'Future iat claim' };
}
// Check for suspiciously long validity
if (decoded.payload.exp - decoded.payload.iat > 86400) {
return { valid: false, reason: 'Excessive token lifetime' };
}
return { valid: true };
}middleBrick's black-box scanning approach is particularly effective for JWT password spraying detection. The scanner tests authentication endpoints without credentials, measuring response characteristics and identifying timing discrepancies that indicate username enumeration vulnerabilities. For JWT-specific analysis, middleBrick examines token generation endpoints for:
- Response time consistency across valid/invalid credentials
- Information leakage in error messages
- Rate limiting effectiveness on token endpoints
- Refresh token endpoint vulnerabilities
The scanner's 12 security checks include authentication testing specifically designed for token-based systems, testing for BOLA (Broken Object Level Authorization) that often accompanies JWT vulnerabilities.
JWT-Specific Remediation
Securing JWT tokens against password spraying requires architectural changes and defensive coding practices specific to token-based authentication.
Implement constant-time comparison for all authentication operations to eliminate timing attacks:
const crypto = require('crypto');
function constantTimeCompare(val1, val2) {
if (val1.length !== val2.length) return false;
let result = 0;
for (let i = 0; i < val1.length; i++) {
result |= val1.charCodeAt(i) ^ val2.charCodeAt(i);
}
return result === 0;
}
// Secure login implementation
app.post('/login', async (req, res) => {
const { username, password } = req.body;
let user;
let validPassword = false;
try {
// Always perform hash comparison, even for non-existent users
user = await db.getUser(username);
if (user) {
validPassword = await comparePasswords(password, user.password);
} else {
// Simulate hash comparison for non-existent user
await comparePasswords(password, '$2b$12$invalidhash');
}
if (!validPassword) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = jwt.sign(
{ sub: user.id, username: user.username },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
res.json({ token });
} catch (error) {
res.status(500).json({ error: 'Authentication failed' });
}
});Add rate limiting at multiple levels for JWT endpoints. Use IP-based and account-based limits:
const rateLimit = require('express-rate-limit');
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // Limit each IP to 5 requests
message: 'Too many authentication attempts',
keyGenerator: (req) => req.ip + ':' + (req.body.username || 'unknown'),
skipSuccessfulRequests: false,
standardHeaders: true,
legacyHeaders: false,
});
const tokenLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
message: 'Too many token requests',
keyGenerator: (req) => req.ip,
});
app.post('/login', loginLimiter, loginHandler);
app.post('/refresh', tokenLimiter, refreshHandler);
// Add account-level rate limiting
const accountAttempts = new Map();
async function checkAccountRateLimit(username) {
const now = Date.now();
const record = accountAttempts.get(username) || { count: 0, windowStart: now };
if (now - record.windowStart > 15 * 60 * 1000) {
record.count = 0;
record.windowStart = now;
}
if (record.count > 5) {
throw new Error('Account temporarily locked');
}
record.count++;
accountAttempts.set(username, record);
}Implement progressive response delays to slow down automated attacks:
let failureCount = 0;
const MAX_FAILURES = 3;
function progressiveDelay() {
if (failureCount < MAX_FAILURES) return 0;
const delay = Math.pow(2, failureCount - MAX_FAILURES) * 1000;
return Math.min(delay, 30000); // Cap at 30 seconds
}
app.post('/login', async (req, res) => {
const start = Date.now();
try {
await checkAccountRateLimit(req.body.username);
// Authentication logic...
} catch (error) {
failureCount++;
const delay = progressiveDelay();
setTimeout(() => {
res.status(429).json({
error: 'Too many attempts',
retryAfter: delay
});
}, delay);
return;
}
});Use refresh token rotation to prevent token replay attacks:
app.post('/refresh', async (req, res) => {
const { refreshToken } = req.body;
try {
const decoded = jwt.verify(refreshToken, process.env.REFRESH_SECRET);
const user = await db.getUserById(decoded.sub);
// Verify the refresh token belongs to this user
if (!user || user.refreshToken !== refreshToken) {
throw new Error('Invalid refresh token');
}
// Rotate refresh token
const newRefreshToken = generateSecureToken();
await db.updateRefreshToken(user.id, newRefreshToken);
const newToken = jwt.sign(
{ sub: user.id, username: user.username },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
res.json({
token: newToken,
refreshToken: newRefreshToken
});
} catch (error) {
res.status(401).json({ error: 'Refresh failed' });
}
});Implement device fingerprinting and behavioral analysis to detect automated password spraying:
function generateFingerprint(req) {
return {
ip: req.ip,
userAgent: req.get('User-Agent'),
acceptLanguage: req.get('Accept-Language'),
screenResolution: req.body.screenResolution, // If available
timezone: req.body.timezone,
};
}
const deviceAnalytics = new Map();
function analyzeDeviceBehavior(fingerprint, username) {
const key = JSON.stringify(fingerprint);
const record = deviceAnalytics.get(key) || { attempts: 0, users: new Set(), lastSeen: Date.now() };
record.attempts++;
record.users.add(username);
record.lastSeen = Date.now();
deviceAnalytics.set(key, record);
// Flag suspicious patterns
if (record.attempts > 10 && record.users.size > 3) {
logger.warn('Suspicious device activity detected', {
fingerprint: fingerprint,
users: Array.from(record.users),
attempts: record.attempts
});
}
}