HIGH pii leakagejwt tokens

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 payload

Static 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 json

middleBrick 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/* | less

Network 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 IDNameSeverity
CWE-200Exposure of Sensitive Information HIGH
CWE-209Error Information Disclosure MEDIUM
CWE-213Exposure of Sensitive Information Due to Incompatible Policies HIGH
CWE-215Insertion of Sensitive Information Into Debugging Code MEDIUM
CWE-312Cleartext Storage of Sensitive Information HIGH
CWE-359Exposure of Private Personal Information (PII) HIGH
CWE-522Insufficiently Protected Credentials CRITICAL
CWE-532Insertion of Sensitive Information into Log File MEDIUM
CWE-538Insertion of Sensitive Information into Externally-Accessible File HIGH
CWE-540Inclusion of Sensitive Information in Source Code HIGH

Frequently Asked Questions

How can I test if my JWT tokens contain PII leakage vulnerabilities?
Use automated scanning tools like 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?
Use only numeric user IDs or UUIDs in the 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.