JWT Authentication: What It Is, How It Works, and Where It Gets Tricky
A practical guide to JWT authentication — token structure, signing algorithms, storage strategy, refresh tokens, revocation, and the security mistakes that create real vulnerabilities.

James Ross Jr.
Strategic Systems Architect & Enterprise Software Developer
JWTs are one of the most misunderstood security mechanisms in web development. Developers reach for them because they have heard they are stateless and scalable, implement them incorrectly, and end up with authentication that is both insecure and unnecessarily complex. Let me give you a clear picture of what JWTs actually are, where they work well, and the security mistakes that matter.
What a JWT Actually Is
A JWT (JSON Web Token) is a base64-encoded JSON object in three parts separated by dots:
header.payload.signature
Header: Specifies the token type and signing algorithm:
{ "alg": "HS256", "typ": "JWT" }
Payload: The claims — data you want to communicate:
{
"sub": "user_01j9abc...",
"email": "james@example.com",
"role": "admin",
"iat": 1740787200,
"exp": 1740873600
}
Signature: A cryptographic signature that verifies the token has not been tampered with.
The critical insight: the payload is not encrypted. It is base64-encoded, which means anyone can decode it and read the contents. Never put secrets, passwords, or sensitive personal data in a JWT payload.
Signing Algorithms
HS256 (HMAC-SHA256): Uses a single shared secret for both signing and verification. Fast and simple, but the same secret must be known to both the token issuer and the verifier. Not suitable when you need multiple services to verify tokens issued by a central authority.
RS256 (RSA-SHA256): Uses a private key to sign and a public key to verify. Multiple services can verify tokens without knowing the private key. The right choice for distributed systems or when tokens are issued by one service and verified by others.
ES256 (ECDSA-SHA256): Like RS256 but with elliptic curve cryptography. Smaller keys and signatures, comparable security. Increasingly preferred over RS256.
Use RS256 or ES256 for any production system. HS256 requires keeping the secret synchronized across all verification points, which is operationally fragile.
Generating JWTs
import * as jose from 'jose'
// Load your keys (store securely, not in source code)
const privateKey = await jose.importPKCS8(process.env.JWT_PRIVATE_KEY!, 'RS256')
const publicKey = await jose.importSPKI(process.env.JWT_PUBLIC_KEY!, 'RS256')
async function createAccessToken(userId: string, role: string) {
return new jose.SignJWT({
sub: userId,
role,
})
.setProtectedHeader({ alg: 'RS256' })
.setIssuedAt()
.setIssuer('https://auth.yourdomain.com')
.setAudience('https://api.yourdomain.com')
.setExpirationTime('15m') // Short-lived access tokens
.sign(privateKey)
}
async function verifyAccessToken(token: string) {
const { payload } = await jose.jwtVerify(token, publicKey, {
issuer: 'https://auth.yourdomain.com',
audience: 'https://api.yourdomain.com',
})
return payload
}
Access Tokens and Refresh Tokens
Short-lived access tokens with long-lived refresh tokens is the standard pattern for balancing security and user experience:
- Access token: Short lifetime (5-15 minutes). Sent with every API request. Stateless — no database lookup needed to verify.
- Refresh token: Long lifetime (7-90 days). Stored securely. Used only to get new access tokens. Single-use (rotation) or persistent.
async function createTokenPair(userId: string, role: string) {
const accessToken = await createAccessToken(userId, role)
const refreshToken = await new jose.SignJWT({ sub: userId, type: 'refresh' })
.setProtectedHeader({ alg: 'RS256' })
.setIssuedAt()
.setExpirationTime('30d')
.sign(privateKey)
// Store refresh token hash in database for revocation
const tokenHash = crypto.createHash('sha256').update(refreshToken).digest('hex')
await db.insert(refreshTokens).values({
userId,
tokenHash,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
})
return { accessToken, refreshToken }
}
Token Storage
Where you store JWTs matters enormously for security.
localStorage: Never store tokens here. Any XSS attack (including via a compromised npm package) can steal tokens from localStorage. If an attacker can run arbitrary JavaScript on your page, they can read localStorage.
Memory (JavaScript variable): Secure against XSS but tokens are lost on page refresh. For SPAs that need to survive refreshes, this means re-authenticating on every load.
HTTP-only cookies: Cannot be read by JavaScript. Protected against XSS. Requires CSRF protection. This is the most secure storage option for browser-based applications.
For single-page applications:
- Store access tokens in memory
- Store refresh tokens in HTTP-only cookies
- Silently refresh access tokens in the background when they expire
// Refresh endpoint sets cookie
app.post('/auth/refresh', async (c) => {
const refreshToken = getCookie(c, 'refresh_token')
if (!refreshToken) throw createError({ statusCode: 401 })
// Verify and rotate refresh token
const newTokens = await rotateRefreshToken(refreshToken)
setCookie(c, 'refresh_token', newTokens.refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'Strict',
maxAge: 30 * 24 * 60 * 60,
path: '/auth/refresh', // Narrow path scope
})
return c.json({ accessToken: newTokens.accessToken })
})
Token Revocation: The Hard Part
JWTs are stateless — once issued, they are valid until they expire, and there is no built-in mechanism to revoke them. This is a real problem:
- User changes their password → old tokens should be invalid
- User is banned → their tokens should be rejected immediately
- Token is stolen → it should be revocable
Solutions:
Short expiry times. If access tokens expire in 5 minutes, the window for a stolen token is small. This is the primary defense.
Blocklist (for access tokens). Store revoked access tokens in Redis until they would naturally expire:
async function revokeToken(tokenId: string, expiresAt: Date) {
const ttl = Math.ceil((expiresAt.getTime() - Date.now()) / 1000)
await redis.setex(`revoked:${tokenId}`, ttl, '1')
}
async function isRevoked(tokenId: string): Promise<boolean> {
const result = await redis.get(`revoked:${tokenId}`)
return result !== null
}
Include a unique JWT ID (jti) claim and check it against the blocklist on each request.
Refresh token rotation. Each time a refresh token is used to get a new access token, the old refresh token is invalidated and a new one is issued. If a stolen refresh token is used, the legitimate user's next refresh will detect the invalidation and force re-authentication.
async function rotateRefreshToken(token: string) {
const payload = await verifyRefreshToken(token)
const tokenHash = hashToken(token)
// Find and invalidate the old token
const storedToken = await db.query.refreshTokens.findFirst({
where: eq(refreshTokens.tokenHash, tokenHash),
})
if (!storedToken || storedToken.expiresAt < new Date()) {
throw new UnauthorizedError('Invalid refresh token')
}
if (storedToken.usedAt) {
// Token was already used — possible token theft, invalidate all user tokens
await db.delete(refreshTokens).where(eq(refreshTokens.userId, storedToken.userId))
throw new UnauthorizedError('Refresh token reuse detected')
}
// Mark as used and issue new pair
await db.update(refreshTokens)
.set({ usedAt: new Date() })
.where(eq(refreshTokens.id, storedToken.id))
return createTokenPair(storedToken.userId, storedToken.role)
}
JWT vs Sessions: The Real Comparison
JWTs add complexity relative to server-side sessions. The scalability argument — "JWTs are stateless so you do not need a session database" — often does not hold up in practice:
- Refresh token revocation requires a database
- Access token revocation requires a Redis blocklist
- Token validation still requires cryptographic operations
The genuine benefits of JWTs are cross-service authentication (a single token works across multiple APIs without them sharing a session database) and mobile/API contexts where cookie-based sessions are awkward.
For single-server applications or applications with a single API, server-side sessions stored in Redis are simpler, more revocable, and just as performant.
JWTs are the right tool when you need them. Know when that is, and when sessions are the better choice.
Designing authentication for a new API or reviewing an existing JWT implementation for security issues? I am happy to help. Book a call: calendly.com/jamesrossjr.