Putting JWTs (JSON Web Tokens) in URLs is generally discouraged but sometimes necessary. Understanding the risks helps you make informed decisions and implement proper safeguards.
Key Takeaways
- 1URLs appear in browser history, server logs, and Referer headers
- 2Prefer Authorization headers or cookies for transporting JWTs
- 3If URL-based, use short-lived tokens (minutes, not hours)
- 4Single-use tokens reduce replay attack window
- 5Email/password reset links are a legitimate exception
"JWTs represent a set of claims as a JSON object that is encoded in a JWS and/or JWE structure... enabling the claims to be digitally signed or integrity protected."— RFC 7519, Section 1
Security Risks
This section covers all the ways JWTs in URLs can leak. Understanding these vectors helps you evaluate whether URL-based tokens are acceptable for your specific use case. Each leak path represents a potential attack surface.
The following table summarizes where URL tokens get exposed and the security implications of each:
| Risk | How JWTs Leak | Impact |
|---|---|---|
| Browser history | URL saved locally | Anyone with device access can steal token |
| Server logs | Access logs include full URLs | Log aggregators see tokens |
| Referer header | Sent to external links | Third parties receive token |
| Shoulder surfing | Visible in address bar | Token exposed visually |
| Shared links | User copies/pastes URL | Token shared accidentally |
The common thread across these risks is persistence. URLs get logged, cached, and shared in ways that body content and headers don't. A token that appears for one second in a URL might exist in logs for years.
When URL JWTs Are Acceptable
Despite the risks, some use cases legitimately require tokens in URLs. These are typically one-time actions where the user clicks a link from outside your application, like email verification or password reset.
| Use Case | Why It's OK | Precautions |
|---|---|---|
| Email verification | One-time use, user-initiated | Short expiry, single-use |
| Password reset | One-time use, limited scope | Very short expiry, invalidate on use |
| Magic login links | Convenience over session tokens | Single-use, short expiry |
| Signed download URLs | Time-limited access | Short expiry, specific resource |
Notice the pattern: every acceptable case involves short-lived, single-use tokens with limited scope. If your use case doesn't fit these criteria, you should use headers or cookies instead.
Best Practices for URL Tokens
If you've determined that URL tokens are necessary for your use case, follow these practices to minimize risk. The key principles are: make tokens short-lived, single-use, and narrowly scoped.
// 1. Short expiration (5-15 minutes)
const token = jwt.sign(
{ userId: user.id, action: 'verify-email' },
SECRET,
{ expiresIn: '15m' } // Short lived!
);
// 2. Single-use tokens
// Store token ID in database, mark as used after first use
const tokenId = crypto.randomUUID();
await db.tokens.create({ id: tokenId, userId, used: false });
const token = jwt.sign({ tid: tokenId, userId }, SECRET, { expiresIn: '1h' });
// On verification:
const { tid } = jwt.verify(token, SECRET);
const tokenRecord = await db.tokens.findUnique({ where: { id: tid } });
if (tokenRecord.used) throw new Error('Token already used');
await db.tokens.update({ where: { id: tid }, data: { used: true } });
// 3. Scope limitation
const token = jwt.sign({
userId: user.id,
scope: 'password-reset', // Limited scope
email: user.email // Bound to specific email
}, SECRET, { expiresIn: '15m' });The code demonstrates three layers of protection: time expiration, single-use tracking via database, and scope limitation. The scope: 'password-reset' ensures that even if leaked, the token can't be used for other actions like accessing the API.
Preferred Alternatives
For most applications, you should avoid URL tokens entirely. Here are the recommended alternatives, listed from most to least secure for different scenarios:
// 1. Authorization header (preferred for APIs)
fetch('/api/protected', {
headers: {
'Authorization': 'Bearer ' + jwt
}
});
// 2. HTTP-only cookie (preferred for web apps)
// Server sets:
res.cookie('token', jwt, {
httpOnly: true, // Not accessible via JavaScript
secure: true, // HTTPS only
sameSite: 'lax', // CSRF protection
maxAge: 3600000 // 1 hour
});
// 3. Session storage + API (for SPAs)
sessionStorage.setItem('token', jwt);
// Token never in URL, stays in memoryAuthorization headers are ideal for API calls. HTTP-only cookies work best for traditional web apps because JavaScript can't access them, preventing XSS attacks from stealing tokens. For SPAs, sessionStorage combined with API calls keeps tokens out of URLs entirely.
If You Must Use URL Tokens
When URL tokens are unavoidable (like email verification links), you can reduce the exposure window with these mitigation techniques. The goal is to minimize how long the token remains visible.
// 1. Immediately remove from URL after reading
useEffect(() => {
const params = new URLSearchParams(location.search);
const token = params.get('token');
if (token) {
// Process token
verifyToken(token);
// Remove from URL (and history)
window.history.replaceState({}, '', location.pathname);
}
}, []);
// 2. Use fragment instead of query string
// Fragments are NOT sent in Referer headers
'https://example.com/verify#token=eyJhbGciOiJIUzI1NiJ9...'
// 3. Add Referrer-Policy header
// Prevents token leaking to external links
res.setHeader('Referrer-Policy', 'no-referrer');The most important mitigation is using history.replaceState to immediately remove the token from the URL after reading it. This prevents the token from appearing in browser history. Using URL fragments instead of query parameters prevents tokens from being sent in Referer headers to external sites.
Finally, let's be clear about what should never appear in URLs under any circumstances.
Never in URLs
- • Long-lived access tokens
- • Refresh tokens
- • Session tokens
- • Tokens with broad scopes
- • Any token meant for repeated use