Token Security & Privacy Best Practices — JWT, API Keys, Sessions
Tokens are the keys to your kingdom. A leaked JWT, exposed API key, or misconfigured session cookie can give attackers full access to user accounts or backend systems. This guide covers the concrete security practices every developer should follow — with code examples and common mistakes to avoid.
#1
cause of data breaches: stolen credentials
84%
of breaches involve human element
15 min
to implement all practices here
$4.45M
average data breach cost (2023)
JWT Security: What Most Developers Get Wrong
JWTs are not encrypted by default — they're base64-encoded and signed. Anyone can decode the payload. The signature only proves the token hasn't been tampered with.
JWT payloads are publicly readable
atob(token.split('.')[1]) reveals the full payload in any browser console. Never put sensitive data (passwords, SSNs, PII) in a JWT payload.Sensitive data in JWT
// Never store sensitive data in JWT payload
const token = jwt.sign({
userId: 123,
email: 'user@example.com',
password: 'hashed_password', // ← NEVER
ssn: '123-45-6789', // ← NEVER
creditCard: '4111111111111111', // ← NEVER
}, secret);Safe JWT payload
// Only store non-sensitive identifiers in JWT
const token = jwt.sign({
sub: '123', // user ID (not sensitive)
role: 'user', // permission level
iat: Date.now(), // issued at
}, secret, { expiresIn: '15m' }); // SHORT expiry!Short expiry times
Access tokens: 15 minutes. Refresh tokens: 7-30 days. Short-lived tokens limit the damage window if stolen.
Use strong secrets
JWT secret must be at least 256 bits (32 bytes) of random data. Use: openssl rand -hex 32
Verify algorithm
Always specify the expected algorithm when verifying: jwt.verify(token, secret, { algorithms: ["HS256"] }). Prevents the "alg: none" attack.
Use RS256 for distributed systems
HS256 requires all services to share the secret. RS256 uses a key pair — only the auth server holds the private key; others verify with the public key.
Where to Store Tokens
Where you store tokens in the browser determines your attack surface. Both options have trade-offs.
| Item | localStorage / sessionStorage | HttpOnly Cookie |
|---|---|---|
| XSS vulnerable? | ✅ Yes — any JS can read it | ❌ No — JS cannot access HttpOnly cookies |
| CSRF vulnerable? | ❌ No — must be sent manually | ✅ Yes — auto-sent with requests |
| Easy to implement? | ✅ Yes | ⚠️ Requires CSRF protection |
| Recommended for? | Low-risk tokens, short sessions | Auth tokens, sensitive sessions |
| Defense needed | Strong CSP, sanitize all user input | SameSite=Strict or CSRF tokens |
Recommended: HttpOnly + SameSite=Strict cookie
res.cookie('accessToken', token, {
httpOnly: true, // JS cannot access
secure: true, // HTTPS only
sameSite: 'strict', // No cross-site sending
maxAge: 15 * 60 * 1000, // 15 minutes
path: '/',
});API Key Security
Hardcoded key
// Never hardcode API keys in source code
const stripe = new Stripe('sk_live_abc123...real_key');
// Never commit them to Git
// .env file with real key committed to repo = disasterEnvironment variable
// Always use environment variables
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
// .env file (NEVER commit this)
// STRIPE_SECRET_KEY=sk_live_...
// .env.example (safe to commit — shows structure)
// STRIPE_SECRET_KEY=your_stripe_secret_key_hereAdd .env to .gitignore immediately
Before you ever type a real secret. If you accidentally committed a key — rotate it immediately, then remove it from history with git filter-branch or BFG Repo Cleaner.
Rotate leaked keys immediately
If a key appears in git history, in logs, or anywhere public — assume it's compromised. Rotate in your provider's dashboard before doing anything else.
Use least-privilege API keys
Create keys with only the permissions they need. Read-only keys for read-only operations. Scoped to specific resources if possible.
Set up secret scanning
GitHub has built-in secret scanning that alerts you when a key is pushed. Enable it under Settings → Security → Secret scanning on all repos.
Use a secrets manager in production
AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault — don't use .env files on production servers. These tools provide audit logs, automatic rotation, and fine-grained access control.
Refresh Token Rotation
Short-lived access tokens + long-lived refresh tokens is the standard pattern. Rotation ensures that if a refresh token is stolen, using the old one immediately invalidates the new one.
How rotation works
When a client uses a refresh token, the server issues a new access token AND a new refresh token, then invalidates the old refresh token. Each refresh is a one-time use.
Reuse detection
If a refresh token is presented that was already rotated, it means it was stolen and reused. Immediately revoke the entire token family and log out the user.
Token families
Group related tokens in a "family." If any member of the family is replayed, revoke all tokens in that family — logging out all sessions for that user.
Silent refresh
Implement a background refresh mechanism so the access token is silently renewed before expiry. Users stay logged in without interruption while maintaining security.
app.post('/auth/refresh', async (req, res) => {
const { refreshToken } = req.cookies;
// Verify refresh token
const payload = jwt.verify(refreshToken, REFRESH_SECRET);
// Check it exists in DB (not revoked)
const stored = await db.refreshTokens.findOne({ token: refreshToken, userId: payload.sub });
if (!stored) return res.status(401).json({ error: 'Invalid refresh token' });
// Rotate: delete old, create new
await db.refreshTokens.delete({ token: refreshToken });
const newRefreshToken = crypto.randomBytes(32).toString('hex');
await db.refreshTokens.create({ token: newRefreshToken, userId: payload.sub });
// Issue new access token
const accessToken = jwt.sign({ sub: payload.sub }, ACCESS_SECRET, { expiresIn: '15m' });
res.cookie('refreshToken', newRefreshToken, { httpOnly: true, secure: true });
res.json({ accessToken });
});Token Revocation
JWTs are stateless — once issued, they're valid until they expire. To revoke them before expiry (logout, password change, account compromise), you need a revocation strategy.
| Item | Short expiry (15 min) | Blocklist/Denylist |
|---|---|---|
| Complexity | Simple — no server state | Requires DB lookup per request |
| Revocation speed | Up to 15 min before token expires | Immediate |
| Scalability | Stateless — scales perfectly | DB lookup on every authenticated request |
| Best for | Most apps — good enough | High-security apps, immediate logout requirements |
Session Security vs JWT — Choosing the Right Approach
Sessions and JWTs are not the same thing. Understanding their differences helps you pick the right authentication strategy for your application architecture.
| Item | Server-Side Sessions | JWT Tokens |
|---|---|---|
| State storage | Server stores session data (DB/Redis) | Client stores token; server is stateless |
| Revocation | Instant — delete from session store | Requires blocklist or short expiry |
| Scalability | Requires shared session store across servers | Stateless — works across any server |
| Microservices | All services need session store access | Each service validates token independently |
| Payload size | Tiny cookie (just a session ID) | Larger — all claims embedded in token |
| Best fit | Monolith apps, when instant revocation needed | Distributed systems, APIs, microservices |
OAuth 2.0 Token Security
Many applications use OAuth 2.0 for third-party authentication (Sign in with Google, GitHub, etc.). OAuth tokens have their own security considerations beyond standard JWTs.
// When initiating OAuth flow, generate a random state
const state = crypto.randomBytes(32).toString('hex');
// Store in session/cookie
req.session.oauthState = state;
// Add to authorization URL
const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?
client_id=${CLIENT_ID}
&redirect_uri=${REDIRECT_URI}
&response_type=code
&scope=openid+email+profile
&state=${state}`; // ← critical anti-CSRF parameter
// On callback — ALWAYS verify state matches
app.get('/auth/callback', (req, res) => {
if (req.query.state !== req.session.oauthState) {
return res.status(403).json({ error: 'State mismatch — potential CSRF attack' });
}
// Proceed with code exchange...
});Never skip the OAuth state parameter
Security Headers That Protect Tokens
import helmet from 'helmet';
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"], // No inline scripts — protects against XSS
styleSrc: ["'self'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", "https://api.yourservice.com"],
frameSrc: ["'none'"], // No iframes
objectSrc: ["'none'"],
},
},
hsts: {
maxAge: 31536000, // 1 year
includeSubDomains: true,
preload: true, // HTTPS only — tokens never sent over HTTP
},
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
}));
// Additional CORS configuration
app.use(cors({
origin: ['https://yourapp.com'], // Whitelist only your domain
credentials: true, // Allow cookies
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
}));Most common token leak vectors
Browser developer tools (localStorage inspection), accidentally committed .env files, server logs that print request headers (including Authorization bearer tokens), insecure HTTP connections, and third-party JavaScript with access to the page DOM. Strong CSP headers and HttpOnly cookies mitigate most of these attack surfaces.