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)

1

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

❌ Bad
// 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

✅ Good
// 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.

2

Where to Store Tokens

Where you store tokens in the browser determines your attack surface. Both options have trade-offs.

ItemlocalStorage / sessionStorageHttpOnly 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 sessionsAuth tokens, sensitive sessions
Defense neededStrong CSP, sanitize all user inputSameSite=Strict or CSRF tokens

Recommended: HttpOnly + SameSite=Strict cookie

For authentication tokens, use HttpOnly cookies with SameSite=Strict (or Lax for cross-site flows). This protects against XSS token theft — the most common attack vector.
javascriptSetting a secure auth cookie (Node.js)
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: '/',
});
3

API Key Security

Hardcoded key

❌ Bad
// 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 = disaster

Environment variable

✅ Good
// 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_here
1

Add .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.

2

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.

3

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.

4

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.

5

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.

4

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.

javascriptRefresh Token Rotation (Node.js)
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 });
});
5

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.

ItemShort expiry (15 min)Blocklist/Denylist
ComplexitySimple — no server stateRequires DB lookup per request
Revocation speedUp to 15 min before token expiresImmediate
ScalabilityStateless — scales perfectlyDB lookup on every authenticated request
Best forMost apps — good enoughHigh-security apps, immediate logout requirements
6

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.

ItemServer-Side SessionsJWT Tokens
State storageServer stores session data (DB/Redis)Client stores token; server is stateless
RevocationInstant — delete from session storeRequires blocklist or short expiry
ScalabilityRequires shared session store across serversStateless — works across any server
MicroservicesAll services need session store accessEach service validates token independently
Payload sizeTiny cookie (just a session ID)Larger — all claims embedded in token
Best fitMonolith apps, when instant revocation neededDistributed systems, APIs, microservices
7

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.

javascriptOAuth State Parameter (CSRF Prevention)
// 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

Omitting the state parameter in OAuth flows makes your application vulnerable to CSRF attacks where an attacker can link their account to a victim's session. Always generate a random state, store it server-side, and verify it matches on the callback before exchanging the code.
8

Security Headers That Protect Tokens

javascriptEssential Security Headers (Express.js + Helmet)
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.

Frequently Asked Questions

Related Security & Privacy Guides

Continue with closely related troubleshooting guides and developer workflows.