JWT Authentication: Security Best Practices in 2026
JSON Web Tokens are everywhere — but most implementations have at least one critical security flaw. The attack surface is larger than it looks: algorithm confusion attacks, token theft via XSS, missing expiry validation, and improper secret management are among the most common vulnerabilities found in production systems. Getting JWT right is not a matter of calling a library and moving on; it requires deliberate decisions at every layer.
This guide covers the full JWT security lifecycle — from signing algorithm selection and token structure through storage, rotation, revocation, and real-world NestJS implementation patterns — so you can build authentication that actually holds up under attack.
Key Takeaways
- Always use RS256 (asymmetric) for distributed systems; HS256 only when the API server is both issuer and verifier
- Store tokens in HttpOnly, Secure, SameSite=Lax cookies — never in localStorage or sessionStorage
- Always validate
exp,iss,aud, andalgclaims — never trust unsignednonealgorithm- Implement refresh token rotation: each refresh issues a new pair and invalidates the old refresh token
- Keep access token TTL short (15 minutes); use opaque refresh tokens stored in the database
- Never store sensitive data (passwords, SSNs, payment info) in JWT payloads — payloads are base64, not encrypted
- Implement token revocation via a Redis denylist or version counter in the database
- Log all token issuance and refresh events for security audit trails
JWT Structure and Claims
A JWT has three parts: header, payload, and signature, separated by dots and base64url-encoded.
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiJ1c2VyXzEyMyIsImVtYWlsIjoidXNlckBleGFtcGxlLmNvbSIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTc0MjM0NTYwMCwiZXhwIjoxNzQyMzQ2NTAwLCJpc3MiOiJodHRwczovL2FwaS5leGFtcGxlLmNvbSIsImF1ZCI6Imh0dHBzOi8vYXBwLmV4YW1wbGUuY29tIn0.
[signature]
Decoded payload:
{
"sub": "user_123",
"email": "[email protected]",
"role": "admin",
"iat": 1742345600,
"exp": 1742346500,
"iss": "https://api.example.com",
"aud": "https://app.example.com"
}
Required claims for production:
sub— unique user identifier (never email alone — emails change)exp— expiry timestamp (always required)iat— issued-at timestamp (detect clock skew)iss— issuer URL (validate against your expected issuer)aud— audience (validate to prevent token reuse across services)jti— JWT ID (unique per token, enables exact revocation)
RS256 vs HS256: Which Algorithm to Use
This is the single most impactful security decision in JWT configuration.
HS256 (HMAC-SHA256) — Symmetric
// Both signing and verifying require the same secret
const token = jwt.sign(payload, process.env.JWT_SECRET, { algorithm: 'HS256' });
const verified = jwt.verify(token, process.env.JWT_SECRET);
Use only when: The service that signs tokens is the same service that verifies them. HS256 is fine for monolithic APIs but dangerous in microservices — any service that can verify tokens can also create them.
RS256 (RSA-SHA256) — Asymmetric
// Sign with private key (only the auth server holds this)
const privateKey = fs.readFileSync('/secrets/jwt-private.pem');
const token = jwt.sign(payload, privateKey, { algorithm: 'RS256', keyid: 'key-2026-01' });
// Verify with public key (any service can do this safely)
const publicKey = fs.readFileSync('/secrets/jwt-public.pem');
const verified = jwt.verify(token, publicKey, {
algorithms: ['RS256'], // NEVER omit this — prevents algorithm confusion
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
});
Generate a production RSA key pair:
# Generate 4096-bit RSA private key
openssl genrsa -out jwt-private.pem 4096
# Extract public key
openssl rsa -in jwt-private.pem -pubout -out jwt-public.pem
Key rotation: Use keyid (kid) in the header. Publish current public keys at /.well-known/jwks.json. Services cache the JWKS and fetch on unknown kid.
HttpOnly Cookie Storage
This is non-negotiable. Storing tokens in localStorage or sessionStorage makes them accessible to any JavaScript running on the page — including injected scripts from XSS attacks.
// NestJS: set HttpOnly cookie after authentication
@Post('login')
async login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) {
const { accessToken, refreshToken } = await this.authService.login(dto);
const cookieBase = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/',
domain: process.env.COOKIE_DOMAIN, // '.example.com' for subdomain sharing
};
res.cookie('access_token', accessToken, {
...cookieBase,
maxAge: 15 * 60 * 1000, // 15 minutes
});
res.cookie('refresh_token', refreshToken, {
...cookieBase,
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
path: '/auth/refresh', // Scope refresh token to only the refresh endpoint
});
return { message: 'Login successful' };
}
// Extract token from cookie in JWT strategy
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(private configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromExtractors([
(req) => req?.cookies?.access_token, // Cookie first
ExtractJwt.fromAuthHeaderAsBearerToken(), // Bearer fallback for API clients
]),
secretOrKey: configService.get('JWT_PUBLIC_KEY'),
algorithms: ['RS256'],
issuer: configService.get('JWT_ISSUER'),
audience: configService.get('JWT_AUDIENCE'),
});
}
async validate(payload: JwtPayload): Promise<AuthenticatedUser> {
// Always check token version against database
const user = await this.usersService.findById(payload.sub);
if (!user || user.tokenVersion !== payload.tokenVersion) {
throw new UnauthorizedException('Token invalidated');
}
return { id: payload.sub, email: payload.email, role: payload.role };
}
}
Refresh Token Rotation
Never issue a long-lived access token. Instead, keep access tokens short-lived and rotate refresh tokens on every use.
// auth.service.ts
@Injectable()
export class AuthService {
async refreshTokens(refreshToken: string, ipAddress: string) {
// 1. Look up the refresh token in the database
const storedToken = await this.db.query.refreshTokens.findFirst({
where: and(
eq(refreshTokens.token, this.hashToken(refreshToken)),
eq(refreshTokens.revoked, false),
gt(refreshTokens.expiresAt, new Date())
),
with: { user: true },
});
if (!storedToken) {
// Possible reuse attack — revoke all tokens for this user
if (storedToken?.userId) {
await this.revokeAllUserTokens(storedToken.userId);
await this.alertService.send({
message: `Refresh token reuse detected for user ${storedToken.userId}`,
severity: 'high',
});
}
throw new UnauthorizedException('Invalid refresh token');
}
// 2. Revoke the used refresh token (rotation)
await this.db
.update(refreshTokens)
.set({ revoked: true, revokedAt: new Date(), revokedByIp: ipAddress })
.where(eq(refreshTokens.id, storedToken.id));
// 3. Issue new token pair
const newAccessToken = this.issueAccessToken(storedToken.user);
const newRefreshToken = await this.issueRefreshToken(
storedToken.user.id,
ipAddress,
storedToken.family // Track token families for reuse detection
);
return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}
private hashToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex');
}
}
The database schema for refresh tokens:
CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(64) NOT NULL UNIQUE, -- SHA256 hash of the actual token
family UUID NOT NULL, -- Token family for reuse detection
expires_at TIMESTAMPTZ NOT NULL,
revoked BOOLEAN DEFAULT false,
revoked_at TIMESTAMPTZ,
revoked_by_ip INET,
created_at TIMESTAMPTZ DEFAULT now(),
created_by_ip INET
);
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_token ON refresh_tokens(token);
Token Revocation Strategies
JWTs are stateless by design — once issued, you cannot "take them back" without additional infrastructure. Here are three approaches ranked by trade-off:
1. Short TTL (15 minutes)
The simplest revocation strategy: access tokens expire quickly enough that revoking them is rarely necessary. Pair with immediate refresh token revocation in the database.
2. Token Version Counter
Store a tokenVersion in the users table. Increment it to invalidate all existing tokens:
// Increment version to logout all sessions
await this.db
.update(users)
.set({ tokenVersion: sql`token_version + 1` })
.where(eq(users.id, userId));
// In JWT payload
const payload = {
sub: user.id,
tokenVersion: user.tokenVersion, // embedded at sign time
};
// In JwtStrategy.validate()
if (user.tokenVersion !== payload.tokenVersion) {
throw new UnauthorizedException();
}
Requires one database lookup per request — acceptable for most applications.
3. Redis Denylist
For immediate revocation without database lookups:
// Revoke specific token by JTI
async revokeToken(jti: string, expiresIn: number): Promise<void> {
const key = `token:revoked:${jti}`;
await this.redis.set(key, '1', 'EX', expiresIn);
}
// Check in JWT strategy before accepting
async validate(payload: JwtPayload): Promise<AuthenticatedUser> {
const revoked = await this.redis.get(`token:revoked:${payload.jti}`);
if (revoked) {
throw new UnauthorizedException('Token revoked');
}
return this.buildUser(payload);
}
Trade-off: One Redis lookup per request, but Redis is O(1) and sub-millisecond. Acceptable for high-security endpoints.
Common Vulnerabilities and Mitigations
Algorithm Confusion Attack
The alg: "none" attack: an attacker strips the signature and sets alg to none, then submits a tampered payload. Libraries that accept unsigned tokens will accept any payload.
// WRONG — never do this
jwt.verify(token, secret); // Accepts alg:none if library allows it
// CORRECT — always specify algorithms explicitly
jwt.verify(token, publicKey, {
algorithms: ['RS256'], // Whitelist only what you use
});
JWT Header Injection (jwk/jku)
Attackers craft a JWT with a jku (JWKS URL) or jwk (inline key) header pointing to their own key server, then sign with their own private key. A vulnerable verifier fetches the attacker's keys and accepts the token.
// WRONG — never fetch keys from the token header
const jwksUri = decodedHeader.jku; // Attacker-controlled!
// CORRECT — always use a pinned, config-driven JWKS URI
const jwksClient = createRemoteJWKSet(new URL(configService.get('JWKS_URI')));
Weak Secrets for HS256
A 32-character ASCII secret for HS256 has about 190 bits of entropy — insufficient. Use at least 256 bits from a cryptographically secure random source:
# Generate a strong HS256 secret
node -e "console.log(require('crypto').randomBytes(64).toString('base64url'))"
NestJS JWT Module Configuration
// auth.module.ts
import { JwtModule } from '@nestjs/jwt';
@Module({
imports: [
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
privateKey: config.get('JWT_PRIVATE_KEY'),
publicKey: config.get('JWT_PUBLIC_KEY'),
signOptions: {
algorithm: 'RS256',
expiresIn: '15m',
issuer: config.get('JWT_ISSUER'),
audience: config.get('JWT_AUDIENCE'),
},
verifyOptions: {
algorithms: ['RS256'],
issuer: config.get('JWT_ISSUER'),
audience: config.get('JWT_AUDIENCE'),
},
}),
}),
],
})
export class AuthModule {}
JWKS Endpoint for Public Key Distribution
// jwks.controller.ts
@Controller('.well-known')
export class JwksController {
@Get('jwks.json')
@Public()
async getJwks() {
const publicKeyPem = this.configService.get('JWT_PUBLIC_KEY');
// Convert PEM to JWK format using the 'jose' library
const publicKey = await importSPKI(publicKeyPem, 'RS256');
const jwk = await exportJWK(publicKey);
return {
keys: [
{
...jwk,
use: 'sig',
alg: 'RS256',
kid: this.configService.get('JWT_KEY_ID'), // e.g., 'key-2026-01'
},
],
};
}
}
Cache this endpoint aggressively — public keys rarely change. Set Cache-Control: public, max-age=3600.
Security Checklist
Before deploying JWT authentication to production, verify:
- Algorithm explicitly set to
RS256orHS256in both sign and verify calls -
exp,iss,audvalidated on every request - Access token TTL ≤ 15 minutes
- Tokens stored in HttpOnly, Secure, SameSite=Lax cookies
- Refresh token rotation implemented — old token revoked on each use
- Refresh token stored as SHA256 hash in database (not plaintext)
-
jticlaim added for targeted revocation capability - Redis denylist checked for high-security endpoints
- JWKS endpoint published for distributed services
- Private keys stored in secrets manager (AWS Secrets Manager, Vault)
- Key rotation procedure documented and tested
- All token issuance and invalidation events logged
Frequently Asked Questions
Is it safe to decode a JWT without verifying the signature?
Decoding without verification is safe for reading the payload but never trust the contents for authorization. Always verify the signature before acting on claims. The jwt.decode() function in most libraries skips verification — only use it for diagnostics or for reading kid from the header before selecting the right key for verification.
Should I use cookies or Authorization headers for browser apps?
HttpOnly cookies for browser applications, Authorization headers for native mobile apps and server-to-server API calls. Cookies are immune to XSS exfiltration (JavaScript cannot read HttpOnly cookies). Mobile apps cannot use cookies effectively and use Bearer tokens stored in the device's secure keystore.
How do I handle token expiry on the frontend?
Intercept 401 responses and attempt a silent refresh before retrying the original request. In React, use an Axios or fetch interceptor. If the refresh also fails (expired or revoked), redirect to login. Keep a single in-flight refresh promise to prevent parallel refresh storms.
What is the difference between access tokens and refresh tokens?
Access tokens are short-lived (15 minutes), stateless, and verified with a public key or shared secret on every API request. Refresh tokens are long-lived (7-30 days), opaque (random strings, not JWTs), and stored server-side in the database. The refresh token endpoint is the only place refresh tokens are used — scope them with a narrow cookie path.
Can I store user roles in the JWT payload?
Yes, but be aware that roles encoded in the token are cached until the token expires. If you revoke a user's admin role, they retain it until their current access token expires (up to 15 minutes). For high-security role changes, also add the user to a Redis denylist or increment their token version to force immediate re-authentication.
How do I implement "remember me" with JWTs?
Issue a longer-lived refresh token (90 days) when the user checks "remember me" vs the standard 30 days. Store a persistent flag in the refresh token database row so you can display and revoke persistent sessions separately in the user's security settings. Never extend the access token TTL — that defeats the purpose.
Next Steps
JWT authentication done right is the foundation of every secure web application. From RS256 signing through HttpOnly cookie storage, refresh token rotation, and revocation strategies, the patterns in this guide protect your users from the most common authentication attacks.
ECOSIRE implements battle-tested authentication architecture — including OIDC integration with Authentik, HttpOnly cookie flows, and Redis-backed token management — across all our backend projects. Explore our security-focused development services to learn how we can harden your authentication layer.
Written by
ECOSIRE TeamTechnical Writing
The ECOSIRE technical writing team covers Odoo ERP, Shopify eCommerce, AI agents, Power BI analytics, GoHighLevel automation, and enterprise software best practices. Our guides help businesses make informed technology decisions.
ECOSIRE
Grow Your Business with ECOSIRE
Enterprise solutions across ERP, eCommerce, AI, analytics, and automation.
Related Articles
API Security 2026: Authentication & Authorization Best Practices (OWASP Aligned)
OWASP-aligned 2026 API security guide: OAuth 2.1, PASETO/JWT, passkeys, RBAC/ABAC/OPA, rate limiting, secrets management, audit logging, and the top 10 mistakes.
AI Fraud Detection for E-commerce: Protect Revenue Without Blocking Sales
Implement AI fraud detection that catches 95%+ of fraudulent transactions while keeping false positive rates under 2%. ML scoring, behavioral analysis, and ROI guide.
API Rate Limiting: Patterns and Best Practices
Master API rate limiting with token bucket, sliding window, and fixed counter patterns. Protect your backend with NestJS throttler, Redis, and real-world configuration examples.