JWT vs session auth is one of those developer debates that generates more heat than light. I have implemented both in production — session auth for traditional server-rendered apps, JWT for API-first ERP systems — and the truth is that both can be implemented securely or insecurely depending on how you use them. The real question is not which is better in theory, but which fits your threat model, architecture, and team's ability to implement correctly. According to the 2024 Verizon DBIR, stolen credentials account for 24% of all data breaches — making authentication one of the highest-impact security areas to get right.
JWTs (JSON Web Tokens) are self-contained tokens that carry user claims (user_id, roles, permissions) and are signed by your server. The client sends the token on every request; the server verifies the signature without a database lookup. This stateless nature is the main selling point: horizontal scaling without shared session storage, no database round trip on every request, and easy cross-service authentication in microservices. The hidden costs: JWTs cannot be invalidated before expiry. If a token is stolen, it is valid until it expires. If a user's permissions change, old tokens reflect old permissions until they expire.
The non-negotiable JWT security requirements: use short expiry for access tokens (15 minutes is my standard), use a separate long-lived refresh token stored in an httpOnly Secure SameSite=Strict cookie (never localStorage), sign with RS256 (asymmetric) for service-to-service, HS256 (symmetric HMAC) for single-service with proper key management, validate all claims including exp, iat, iss, and aud on every request, and implement a token blacklist for the access token during the refresh window (a Redis set of revoked JTIs works well).
// NestJS JWT + Refresh Token auth implementation
import { Injectable } from '@nestjs/common'
import { JwtService } from '@nestjs/jwt'
import { Response } from 'express'
import * as bcrypt from 'bcrypt'
import { createHash } from 'crypto'
@Injectable()
export class AuthService {
constructor(
private jwtService: JwtService,
private usersService: UsersService,
private redisService: RedisService,
private prisma: PrismaService,
) {}
async login(email: string, password: string, res: Response) {
const user = await this.usersService.findByEmail(email)
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
throw new UnauthorizedException('Invalid credentials')
}
// Short-lived access token (15 min)
const accessToken = this.jwtService.sign(
{ sub: user.id, email: user.email, roles: user.roles, tenantId: user.tenantId },
{ expiresIn: '15m', algorithm: 'RS256' }
)
// Long-lived refresh token (7 days)
const refreshToken = crypto.randomBytes(64).toString('hex')
const tokenHash = createHash('sha256').update(refreshToken).digest('hex')
// Store hash in DB (never the raw token)
await this.prisma.refreshToken.create({
data: {
userId: user.id,
tokenHash,
deviceId: res.req.headers['x-device-id'] as string,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
}
})
// Set refresh token in httpOnly cookie
res.cookie('refresh_token', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
path: '/auth/refresh',
})
return { accessToken } // Return access token in body (stored in memory by client)
}
async refresh(refreshToken: string, res: Response) {
const tokenHash = createHash('sha256').update(refreshToken).digest('hex')
const record = await this.prisma.refreshToken.findUnique({
where: { tokenHash },
include: { user: true },
})
if (!record || record.expiresAt < new Date()) {
throw new UnauthorizedException('Invalid or expired refresh token')
}
// Rotation: delete old, issue new
await this.prisma.refreshToken.delete({ where: { id: record.id } })
return this.login(record.user.email, /* re-issue */)
}
}From my experience building ERP authentication: implement a refresh token rotation strategy where each refresh generates a new refresh token and invalidates the previous one. Store the current valid refresh token hash in the database. This gives you revocation capability (delete the DB record to log out) without the full overhead of session storage. If you detect a refresh token being reused after rotation (the old token comes in after a new one was issued), it indicates token theft — immediately invalidate all refresh tokens for that user.
Traditional session auth stores a session ID in a cookie, and the server looks up session data on every request (typically from Redis). The advantages over JWT: immediate revocation (delete the session record), can store unlimited session data, simpler to implement correctly (fewer ways to make the algorithm=none mistake). The disadvantages: requires shared session storage for horizontal scaling (Redis solves this), slightly higher latency per request (Redis lookup adds 1-5ms), session fixation attacks require careful handling.
The most dangerous JWT implementation mistake is accepting the alg=none JWT vulnerability. Early JWT libraries allowed the algorithm field in the token header to be set to 'none', meaning no signature was required. An attacker could forge a token with any claims by setting alg=none. Modern libraries have fixed this, but only if configured correctly: explicitly set the allowed algorithms and never accept alg=none. In Node.js with jsonwebtoken: always pass algorithms: ['HS256'] to the verify function. If you let the library read the algorithm from the token header, you may be vulnerable depending on the library version.
Storing JWT access tokens in localStorage is a common antipattern that opens your application to XSS attacks. Any JavaScript running on your page (including injected scripts from XSS) can read localStorage and steal tokens. Store access tokens in memory (JavaScript variable) and refresh tokens in httpOnly cookies. Yes, this means the access token is lost on page refresh — solve this by calling your refresh endpoint on app initialization to get a new access token using the httpOnly cookie. This pattern gives you both XSS resistance (httpOnly cookie) and CSRF resistance (short-lived access token in memory, not a cookie).
For API-first ERP systems at Commsult Indonesia, I use a hybrid approach: JWT access tokens (15-minute expiry, RS256 signed, stored in memory), refresh tokens (7-day expiry, stored as httpOnly Secure cookies), refresh token rotation with database-backed revocation (PostgreSQL table), and Redis blacklist for the current 15-minute window. This gives me the stateless verification benefits of JWT with revocation capability comparable to sessions. The database record for refresh tokens also enables 'log out everywhere' by deleting all records for a user ID.
ERP systems often need multiple concurrent sessions (desktop + mobile) and multi-tenant isolation. For multiple devices: issue separate refresh tokens per device, store device fingerprint alongside the token, allow selective revocation of individual device sessions. For multi-tenancy: include tenant_id in the JWT payload, validate tenant_id matches the requested resource on every API call, use separate signing keys per tenant for high-security deployments. Implement refresh token family invalidation — if any token in a refresh chain shows signs of reuse, invalidate all tokens in that family.
My implementation checklist for every auth system: access token expiry ≤ 15 minutes, refresh tokens in httpOnly Secure SameSite=Strict cookies, refresh token rotation enabled, explicit algorithm whitelist (no alg=none), token revocation mechanism (Redis blacklist or DB record), rate limiting on /login and /refresh endpoints, account lockout after N failures, MFA available (TOTP minimum), auth events logged with IP and user agent, session invalidation on password change. This covers the most common authentication attack vectors without overengineering for threats that do not apply to your threat model.
Sources & Further Reading