Pii Leakage with Jwt Tokens
How PII Leakage Manifests in JWT Tokens
PII leakage in JWT tokens occurs when sensitive personal information is embedded directly in token claims without proper safeguards. JWTs are commonly used for authentication and authorization, but developers often make the critical mistake of including raw PII in the payload.
The most common manifestation is including user identifiers like email addresses, phone numbers, or government IDs in the sub (subject) claim or custom claims. For example:
// Vulnerable JWT creation
const token = jwt.sign({
sub: user.email, // PII directly exposed
name: user.fullName, // Full name exposed
phone: user.phoneNumber, // Phone number exposed
role: user.role
}, process.env.JWT_SECRET, { expiresIn: '1h' });When this token is transmitted in HTTP headers or stored in browser storage, the PII becomes accessible to anyone who can intercept or inspect the token. Even when tokens are transmitted over HTTPS, they remain vulnerable at the endpoints—logged in server logs, browser developer tools, or accidentally committed to version control.
Another critical issue is using JWTs for search functionality. When JWTs contain searchable PII like email addresses or usernames, attackers can exploit this through timing attacks or enumeration. Consider this vulnerable pattern:
// Vulnerable search endpoint
app.get('/api/users/search', (req, res) => {
const token = req.headers.authorization.split(' ')[1];
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Directly using PII from token for search
const results = db.query(`
SELECT * FROM users
WHERE email LIKE '%${decoded.sub}%'
OR name LIKE '%${decoded.name}%'
`);
res.json(results);
});This creates a perfect storm: the token contains PII, and that PII is used directly in database queries without sanitization, enabling both information disclosure and potential SQL injection if the token is malformed.
Log injection is another severe manifestation. When JWTs containing PII are logged without masking, they persist in log files indefinitely. Many logging systems don't automatically mask JWT claims, and structured logging can inadvertently include the entire decoded payload:
// Vulnerable logging
app.use((req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
if (token) {
const decoded = jwt.decode(token);
console.log(`User ${decoded.sub} accessed ${req.path}`); // PII in logs
}
next();
});The problem compounds in distributed systems where logs from multiple services are aggregated, creating a comprehensive map of user PII across your entire infrastructure.
JWT-Specific Detection
Detecting PII leakage in JWT tokens requires examining both the token creation process and the runtime behavior. Start by auditing your JWT generation code for direct inclusion of PII:
# Scan for vulnerable JWT patterns
rg "jwt\.sign" --type js -A 3 -B 1
# Look for patterns like:
# - sub: user.email
# - email, phone, ssn in claims
# - user PII directly in payloadStatic analysis tools can identify these patterns, but runtime detection is crucial. Use middleware to inspect tokens before they're processed:
const piiPatterns = [
/\b\d{3}-\d{2}-\d{4}\b/g, // SSN
/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, // Email
/\b\+?[1-9]\d{1,14}\b/g, // Phone (E.164)
/\b\d{3}-\d{2}-\d{4}\b/g // SSN variations
];Implement a JWT inspection middleware:
function inspectJWT(req, res, next) {
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
const token = authHeader.substring(7);
try {
const decoded = jwt.decode(token);
if (decoded) {
const payload = JSON.stringify(decoded);
const piiMatches = piiPatterns.reduce((acc, pattern) => {
const matches = payload.match(pattern);
if (matches) acc.push(...matches);
return acc;
}, []);
if (piiMatches.length > 0) {
console.warn(`PII detected in JWT: ${piiMatches.join(', ')}`);
// Flag for security monitoring
}
}
} catch (err) {
// Invalid token - still log the attempt
console.debug('JWT inspection failed', err.message);
}
}
next();
}For comprehensive scanning, use automated tools like middleBrick that specifically test for PII leakage in JWT endpoints:
# Scan your API for PII leakage in JWT tokens
middlebrick scan https://api.example.com --test pii-leakage --output jsonmiddleBrick performs active testing by submitting crafted requests and analyzing responses for leaked PII, checking if JWT claims are reflected in error messages, API responses, or logs. It also tests for common JWT vulnerabilities like weak signing algorithms and improper claim validation.
Log analysis is critical for detection. Search your aggregated logs for JWT patterns and inspect for PII exposure:
# Find JWTs in logs
grep -r "ey.*\.ey.*\..*" /var/log/app/ | head -20
# Check for PII in logged claims
grep -E "(email|phone|ssn|name)" /var/log/app/* | lessNetwork monitoring can also detect PII leakage by inspecting JWTs in transit. Tools like Wireshark can be configured to decode JWTs and highlight claims containing PII patterns.
JWT-Specific Remediation
Remediating PII leakage in JWT tokens requires architectural changes to how you handle user identification and authorization. The most effective approach is to eliminate raw PII from tokens entirely and use references instead.
Replace direct PII claims with user IDs:
// Vulnerable - contains PII
const vulnerableToken = jwt.sign({
sub: user.email, // DON'T: email in token
name: user.fullName, // DON'T: full name in token
phone: user.phoneNumber // DON'T: phone in token
}, process.env.JWT_SECRET);Instead, use minimal claims with user IDs:
// Secure - only IDs and roles
const secureToken = jwt.sign({
sub: user.id.toString(), // DO: numeric/user ID only
userId: user.id, // DO: explicit user ID
role: user.role, // DO: authorization data only
permissions: ['read', 'write']
}, process.env.JWT_SECRET, { expiresIn: '1h' });This approach ensures that even if a token is compromised, the attacker only obtains a reference ID, not usable PII. The actual user data must be retrieved from your database using the ID.
Implement a secure token validation layer:
const jwtMiddleware = (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing token' });
}
const token = authHeader.substring(7);
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Validate token structure - no unexpected PII claims
const allowedClaims = ['sub', 'userId', 'role', 'permissions', 'iat', 'exp'];
const unexpectedClaims = Object.keys(decoded).filter(
claim => !allowedClaims.includes(claim)
);
if (unexpectedClaims.length > 0) {
console.warn(`Suspicious claims in JWT: ${unexpectedClaims.join(', ')}`);
return res.status(401).json({ error: 'Invalid token' });
}
// Verify sub claim is numeric ID, not PII
if (isNaN(decoded.sub)) {
console.warn(`Non-numeric sub claim: ${decoded.sub}`);
return res.status(401).json({ error: 'Invalid token' });
}
req.user = { id: parseInt(decoded.sub), role: decoded.role };
next();
} catch (err) {
console.error('JWT validation error:', err.message);
res.status(401).json({ error: 'Invalid token' });
}
};For logging and monitoring, implement PII masking:
const maskPII = (data) => {
if (typeof data !== 'object') return data;
return Object.entries(data).reduce((acc, [key, value]) => {
if (typeof value === 'string') {
if (key.match(/email|phone|ssn|name/i)) {
acc[key] = maskString(value);
} else if (key === 'sub' && !/\d+/.test(value)) {
acc[key] = maskString(value);
} else {
acc[key] = value;
}
} else if (typeof value === 'object') {
acc[key] = maskPII(value);
} else {
acc[key] = value;
}
return acc;
}, {});
};
const maskString = (str) => {
if (str.length <= 4) return '*'.repeat(str.length);
return str[0] + '*'.repeat(str.length - 2) + str.slice(-1);
};
// Use in logging
app.use((req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
if (token) {
try {
const decoded = jwt.decode(token);
if (decoded) {
const masked = maskPII(decoded);
console.log(`User ${masked.sub} accessed ${req.path}`);
}
} catch {
console.log(`User accessed ${req.path}`);
}
}
next();
});Implement runtime token inspection in your authentication service:
class SecureAuthService {
validateToken(token) {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Check for PII in claims
const piiIndicators = ['email', 'phone', 'ssn', '@', '+', '-'];
const hasPII = piiIndicators.some(indicator =>
JSON.stringify(decoded).includes(indicator)
);
if (hasPII) {
throw new Error('Token contains suspicious content');
}
return decoded;
} catch (err) {
throw new Error('Token validation failed');
}
}
}For existing systems with PII in tokens, implement a migration strategy. Create a token migration endpoint that exchanges old tokens for new ones:
app.post('/api/migrate-token', jwtMiddleware, async (req, res) => {
try {
// Verify the user still exists and fetch fresh data
const user = await db.users.findById(req.user.id);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
// Create new token with only ID
const newToken = jwt.sign(
{ sub: user.id.toString(), role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
res.json({ newToken });
} catch (err) {
res.status(500).json({ error: 'Migration failed' });
}
});Finally, implement comprehensive monitoring and alerting for PII leakage attempts:
const monitorPIIAttempts = (req, res, next) => {
const piiIndicators = ['email=', 'phone=', 'ssn=', 'user[email]'];
const suspicious = piiIndicators.some(indicator =>
req.url.includes(indicator) ||
JSON.stringify(req.body).includes(indicator)
);
if (suspicious) {
console.warn(`PII injection attempt: ${req.url}`, {
ip: req.ip,
userAgent: req.headers['user-agent']
});
// Alert security team
alertSecurityTeam({
type: 'pii_attempt',
url: req.url,
ip: req.ip,
timestamp: new Date().toISOString()
});
}
next();
};Related CWEs: dataExposure
| CWE ID | Name | Severity |
|---|---|---|
| CWE-200 | Exposure of Sensitive Information | HIGH |
| CWE-209 | Error Information Disclosure | MEDIUM |
| CWE-213 | Exposure of Sensitive Information Due to Incompatible Policies | HIGH |
| CWE-215 | Insertion of Sensitive Information Into Debugging Code | MEDIUM |
| CWE-312 | Cleartext Storage of Sensitive Information | HIGH |
| CWE-359 | Exposure of Private Personal Information (PII) | HIGH |
| CWE-522 | Insufficiently Protected Credentials | CRITICAL |
| CWE-532 | Insertion of Sensitive Information into Log File | MEDIUM |
| CWE-538 | Insertion of Sensitive Information into Externally-Accessible File | HIGH |
| CWE-540 | Inclusion of Sensitive Information in Source Code | HIGH |
Frequently Asked Questions
How can I test if my JWT tokens contain PII leakage vulnerabilities?
middleBrick which specifically tests for PII leakage in JWT endpoints. You can also implement runtime inspection middleware that decodes tokens and checks for PII patterns using regex matching for emails, phone numbers, SSNs, and other identifiers. Static code analysis can identify vulnerable JWT creation patterns where PII is directly embedded in claims.What's the safest way to include user identification in JWT tokens without leaking PII?
sub claim, never raw PII like emails or phone numbers. Include only authorization-related claims such as roles and permissions. Store all actual user data in your database and retrieve it using the ID from the token. This approach ensures that even if a token is compromised, no usable PII is exposed.