Authentication tokens: JWT, Paseto, and session tokens — the decision tree I always needed
Why are we still arguing about JWT as if the problem is the format and not the threat model? We've had this debate for years, and every time someone shows up saying "JWT is insecure" or "Paseto replaces it," I wonder if we're even talking about the same problem. The token format isn't what breaks systems — it's the lack of judgment about when to use each one.
My thesis is uncomfortable: there's no such thing as the perfect token. There's only the right token for the context, the team, and the actual threats of each system. JWT has documented problems. Paseto improves several of them but it's not magic. And opaque session tokens — which almost nobody mentions in these debates — are still the simplest and most secure option for the majority of general-purpose web applications. If you're building something with Next.js and you don't have an explicit case for stateless tokens, you probably don't need them.
The core mess: what RFC 7519 says and what it doesn't
Starting from the source. RFC 7519 defines JWT as a compact means for representing claims transferred between two parties. The structure is well known: header + payload + signature in base64url, separated by dots. What the RFC does not say is that JWT is secure by default — that depends on the algorithm chosen, how the signature is handled, and how the server validates the token.
The most famous historical problem with JWT isn't the structure: it's the "alg": "none" claim that some libraries accepted without requiring a signature, and the RS256 vs HS256 confusion that enabled algorithm confusion attacks. Both are implementation bugs, not format bugs. But the format allowed them. That matters.
What RFC 7519 also doesn't solve:
- Revocation: a signed JWT is valid until it expires. If you need to invalidate it early (logout, password change, session compromise), you need a blocklist or some external mechanism. That eliminates part of the stateless benefit.
- Size: a JWT with typical authentication claims weighs between 300 and 600 bytes. On every request. In HTTP headers. Not a disaster, but not free either.
- Confidentiality: the payload of a signed JWT (JWS) is base64url-encoded, not encrypted. Anyone who intercepts the token can read the claims. For sensitive data you need JWE, which adds implementation complexity.
None of this makes JWT bad. It makes it specific. And that specificity is exactly what the decision tree has to capture.
Paseto: what it actually improves and where the hype outpaces reality
Paseto was born with an honest premise: eliminate the dangerous choices that JWT leaves in the implementer's hands. In JWT you can pick alg: none, use HS256 with a weak key, ignore exp validation. Paseto eliminates that error surface by fixing algorithms per version.
In Paseto v4 (the current recommended version):
v4.localuses XChaCha20-Poly1305 for authenticated encryption (encrypts and authenticates in a single operation).v4.publicuses Ed25519 for asymmetric signing.
No alg: none. No insecure options. The protocol simply doesn't expose them.
But — and this is what the hype usually skips — Paseto does not solve the revocation problem. A valid v4.public token stays valid until it expires, same as JWT. If you need to revoke sessions in real time, you still need server-side state. The problem was never the signing algorithm: it was the stateless model itself.
On top of that, Paseto adoption in the TypeScript/Node.js ecosystem is considerably smaller than JWT's. There's an official library (paseto) maintained by Panva (the same author as jose), but support in frameworks, debugging tools, and third-party documentation is nowhere near the JWT ecosystem. That's a real operational cost for teams that aren't crypto experts.
When Paseto v4 makes sense:
- New systems where the team can invest in the learning curve.
- APIs handling sensitive data that want
v4.local(encryption built into the token). - Teams that want to reduce error surface around algorithm selection.
When Paseto doesn't add enough value to justify the cost:
- Existing systems with a properly implemented JWT setup (HMAC with a strong key,
expandissvalidation, fixed algorithm). - Small teams with little time to invest in adopting new tooling.
- Cases where revocation is a core requirement — at that point, the token format is irrelevant.
The decision tree: questions in order
Before the code, the judgment. These questions have to be answered in order because each one filters out options:
Do you need to invalidate tokens before they expire
(logout, password change, account compromise)?
│
├── YES → Opaque session tokens + server-side store (Redis, DB)
│ JWT or Paseto with a blocklist (kills the stateless advantage)
│
└── NO → Do you have multiple services consuming the token
without centralized coordination?
│
├── YES → JWT (RS256/ES256) or Paseto v4.public
│ (local verification, no call to the auth server)
│
└── NO → Does the payload contain sensitive data
that shouldn't be readable if the token is intercepted?
│
├── YES → Paseto v4.local (encrypted + authenticated)
│ or JWE if you already have JWT infrastructure
│
└── NO → JWT (HS256 with a strong key) or
opaque session tokens are both valid.
Pick whichever is simpler for the team.
My point with this tree: most web applications with a single backend and user sessions fall into the "NO / NO / NO" branch — and the right answer there is opaque session tokens. They're a cryptographically secure random string, stored in an HttpOnly + Secure + SameSite=Strict cookie, with session state on the server. Nothing to blindly revoke, no crypto to implement, nothing to debug with jwt.io.
Minimal reproducible implementation in TypeScript
Opaque session token (the most common case)
import crypto from "node:crypto";
// Generate an opaque token — 32 bytes = 256 bits of entropy
function generateSessionToken(): string {
return crypto.randomBytes(32).toString("hex");
}
// On the login response, set the cookie like this:
// Set-Cookie: session=<token>; HttpOnly; Secure; SameSite=Strict; Path=/
// On each request, look up the token in your store (Redis, DB)
async function validateSession(
token: string
): Promise<UserSession | null> {
// The token carries no state of its own — all info lives on the server
return await sessionStore.get(token) ?? null;
}JWT with RS256 (for multi-service architectures)
import { SignJWT, jwtVerify, generateKeyPair } from "jose";
// Generate the key pair once and store it securely
const { privateKey, publicKey } = await generateKeyPair("RS256");
// Token signing — iss and exp are mandatory for correct validation
async function signToken(userId: string): Promise<string> {
return new SignJWT({ sub: userId })
.setProtectedHeader({ alg: "RS256" })
.setIssuedAt()
.setIssuer("https://auth.myapp.com") // iss: who issued the token
.setAudience("https://api.myapp.com") // aud: who it's valid for
.setExpirationTime("15m") // short exp — no easy revocation
.sign(privateKey);
}
// Verification — audience and issuer must match
async function verifyToken(token: string) {
const { payload } = await jwtVerify(token, publicKey, {
issuer: "https://auth.myapp.com",
audience: "https://api.myapp.com",
});
return payload;
}Paseto v4.public (asymmetric, no dangerous options)
import { V4 } from "paseto";
// Paseto v4.public uses Ed25519 — the algorithm is fixed by the protocol
const secretKey = await V4.generateKey("public");
async function signPasetoToken(userId: string): Promise<string> {
return V4.sign(
{
sub: userId,
exp: new Date(Date.now() + 15 * 60 * 1000).toISOString(), // 15 minutes
},
secretKey,
{ footer: { iss: "https://auth.myapp.com" } }
);
}
async function verifyPasetoToken(token: string) {
// No option to swap the algorithm — that's exactly the point
return V4.verify(token, secretKey.publicKey);
}Common mistakes that aren't obvious
1. JWT with HS256 shared across services HMAC with a shared secret key means any service that can verify the token can also issue it. In microservice architectures that's a real attack surface. RS256 or ES256 separate the signing key (private, only the issuer has it) from the verification key (public, any service can have it).
2. Assuming "stateless" eliminates state If you implement revocation with a blocklist, you already have state. If you check the token against the DB on every request to verify the user is still active, you already have state. At that point, an opaque session token is simpler because it doesn't add signature verification overhead on top of the DB access.
3. JWT payload on the client The payload of a signed JWT is readable by anyone (base64url is not encryption). If you're storing roles, permissions, or any data you don't want exposed on the client, use JWE or don't put it in the token. This isn't a JWT bug — it's in the spec — but in practice a lot of teams discover it late.
4. Long expiration as a UX fix
Sometimes teams push exp out to 30 days to avoid annoying users with re-logins. That turns a token without revocation into a real security problem. The right solution is a short-lived access token (15–60 minutes) plus a refresh token with rotation — not stretching the access token's exp.
If you're using Next.js Middleware to protect routes with JWT, the access + refresh token model is especially relevant — I went deeper on that in the post about authorization patterns in Next.js 16 Middleware.
What you can't conclude without your own data
This matters: everything above is analysis based on the spec and design principles. There are things this post cannot resolve because they depend on variables specific to each system:
- Real revocation latency: how much a Redis blocklist impacts throughput depends on architecture, store size, and access patterns. I don't have those numbers for your system.
- Signature verification overhead: the difference between HS256, RS256, and Ed25519 in real throughput is measurable but varies by hardware, library, and request volume. If that's critical for your system, measure it with a reproducible test in your own environment.
- Paseto compatibility with your stack: not every framework and proxy speaks Paseto. Before adopting it, verify support at every layer of the stack.
The thesis of this post doesn't depend on those numbers. But implementation decisions do.
FAQ: questions I get all the time on this topic
Is JWT insecure?
Not intrinsically. RFC 7519 defines a valid structure. The historical problems (like alg: none) were implementation bugs in specific libraries that accepted null algorithms. A properly implemented JWT — with a fixed algorithm, exp, iss, and aud validation, and a strong key — is secure for most use cases. The problem wasn't the format: it was the excess flexibility that left too many dangerous decisions in the developer's hands.
Does Paseto replace JWT? Technically it can cover the same use cases as signed JWT. But "replace" implies migrating ecosystem, tooling, and team knowledge. Paseto improves security ergonomics (no dangerous options, fixed algorithms) but doesn't solve revocation or change the stateless model. For new systems with a team willing to invest in the curve, it's a solid option. For existing, well-implemented JWT systems, the migration cost is rarely justified by a format change alone.
When should I use opaque session tokens instead of JWT? When the application is a monolith or has a single backend, when you need immediate revocation, when the team is small and you want to reduce implementation surface, or when you have no clear case for stateless tokens. Opaque session tokens with an HttpOnly cookie are the simplest pattern and have decades of operational practice behind them.
Can I store the JWT in localStorage? You can, but it's not recommended for authentication tokens. localStorage is accessible from JavaScript, which exposes it to XSS. An HttpOnly cookie carrying the token — opaque or JWT — is not accessible from client-side JavaScript. If the application has any XSS vector (including third-party dependencies), localStorage amplifies the damage.
How do I handle JWT refresh in Next.js? The typical pattern is a short-lived access token (15 minutes) in a cookie or client memory, and a long-lived refresh token in an HttpOnly cookie. Next.js Middleware can intercept requests with an expired access token, do a transparent refresh, and continue. The complexity lies in refresh token rotation and avoiding race conditions when multiple tabs trigger a refresh simultaneously.
Which JWT library should I use in TypeScript?
jose by Panva is the most solid recommendation today — it's what Next.js uses internally, it's RFC-compliant, actively maintained, and works on Edge Runtime. jsonwebtoken is still popular but has no native Edge support and limitations with modern algorithms. For Paseto, paseto by the same author.
My position, without ambiguity
After watching systems migrate to JWT out of hype and end up building a full blocklist (which threw away the only benefit of the model), and systems that stuck with simple session cookies and work perfectly at scale — my position is this:
Start with opaque session tokens. If at some point the system grows into an architecture where multiple independent services need to verify identity without centralized coordination, that's when JWT or Paseto make real sense. Not before.
The uncomfortable part: the JavaScript ecosystem tends to overcomplicate authentication. There are "turnkey" auth libraries that use JWT internally for everything, even for monolithic applications where it adds no value. The result is extra operational complexity (key management, rotation, refresh) with no clear technical benefit.
If you want to go deeper into the validation layer that should surround any of these decisions, the post on Zod for runtime validation connects well here — validating the token payload before using it is a step that gets skipped more often than it should.
Original sources:
- RFC 7519 — JSON Web Token (JWT): https://datatracker.ietf.org/doc/html/rfc7519
- Paseto — Platform-Agnostic Security Tokens: https://paseto.io/
Related Articles
Zod on the server and the client: the schema you define once and the three ways it breaks in runtime
Zod sells itself as "define once, validate everywhere." In Next.js 16 with Server Actions, edge middleware, and API routes, that's only partially true. Three concrete failure modes and the pattern that prevents them.
Prisma query logging and PostgreSQL: when the ORM is enough and when you have to go deeper
Prisma Client logs show you what queries the ORM generated. PostgreSQL has its own observability layer. Confusing the two is the source of incomplete diagnostics. Here I separate the two planes and give you a concrete criterion for deciding which one to look at first.
Next.js App Router Caching: revalidate, dynamic, and no-store Without the Folklore
The problem with Next.js App Router caching isn't memorizing flags. It's understanding what freshness each piece of data actually needs and treating that as an explicit contract — not as an isolated trick you reach for when something breaks.
Comments (0)
What do you think of this?
Drop your comment in 10 seconds.
We only use your login to show your name and avatar. No spam.