JWT vs PASETO: Why PASETO Exists, How It Differs, and When to Switch

Last updated:

PASETO (Platform-Agnostic SEcurity TOkens) is a token format designed as a safer replacement for JWT. The headline difference is structural: PASETO encodes the cryptographic algorithm in the token's version prefix (v4.public. or v4.local.), not in a parseable header that an attacker can rewrite. That single design choice eliminates the entire alg-confusion attack family that has plagued JWT for a decade — alg: none, HS256/RS256 key confusion, and downgrade attacks all become impossible because the algorithm is part of the protocol version, not a token field. PASETO is also versioned (v3 for NIST-approved primitives, v4 for modern XChaCha20/Ed25519), splits symmetric and asymmetric tokens into distinct purposes (local vs public) that cannot be confused, and ships with fixed algorithm choices per version so application developers never pick crypto. For teams that have been burned by JWT footguns, or for greenfield projects that want a JWT-shaped token without the historical baggage, PASETO is the modern alternative worth knowing.

Working with a token and not sure if it's a JWT or PASETO? Paste it into Jsonic's JWT Decoder— it detects the format from the prefix, parses claims, and flags the algorithm so you can see exactly what you're dealing with before you write verification code.

Decode a token

The case against JWT: alg confusion, weak defaults, footguns

JWT's design predates the security lessons of the 2010s. The format puts the algorithm in a Base64-encoded header that the token itself declares — {"alg":"HS256","typ":"JWT"}— and the verifier is supposed to read that header to decide how to verify. Every weakness in JWT's deployment record traces back to that one design choice.

The alg: none attack: a verifier that trusts the header alg without an allowlist accepts a token with "alg":"none" and an empty signature. CVEs from libraries that shipped this default have been filed against python-jose, node-jsonwebtoken, jwt-go, java-jwt, and others. The attack is trivial: swap the header, blank the signature, and the verifier hands you whatever claims you want.

The HS256/RS256 confusion: an RS256 verifier holds an RSA public key. If the verifier reads alg from the token header and sees HS256, it interprets that public key as an HMAC secret. The attacker, who also has the public key (it's public), can sign a forged token with HMAC using that key, and the verifier accepts it. The mitigation — pin the algorithm in the verifier — is the documented best practice, but it is opt-in. The default behavior in many libraries is to trust the token.

Beyond the attack-driven CVEs, JWT has secondary footguns: encrypted vs signed tokens (JWE vs JWS) share visual shape but are not interchangeable; kid header injection has driven SQL injection and SSRF; clock-skew handling is underspecified; and the standard claims set (exp, nbf, iat) is enforced inconsistently across libraries. For a deeper survey, see our JWT best practices guide.

What PASETO is (Platform-Agnostic SEcurity TOkens)

PASETO is a token format proposed in 2018 by Scott Arciszewski as a direct response to the JWT family of vulnerabilities. The PASETO specification (currently maintained as PASETO v3/v4 with formal test vectors at paseto.io) defines a token shape that looks superficially like JWT — dot-separated, mostly base64url-encoded — but with fundamentally different parsing and verification semantics.

A PASETO token has this structure: version.purpose.payload[.footer]. The version is v3 or v4. The purpose is local (symmetric encryption) or public (asymmetric signing). The payload is the encrypted or signed claims. The optional footer is unencrypted but authenticated data — useful for key IDs or routing hints.

v4.public.eyJzdWIiOiJ1c2VyXzEyMyIsImV4cCI6IjIwMjYtMDUtMjRUMDA6MDA6MDBaIn0...
v4.local.b9kSn7nMa5JtkqEmSc-cVu3W4pNTQ7iZsHxYpRkW1U0HVH...
v3.public.eyJjbGFpbXMiOnRydWV9ALb3vTzZb...
v3.local.JvdVM9YviGsBn5gJtJ_xQ_RfFnGUtKR3...

The first four characters of the token are the algorithm. A parser cannot mistake a v4.public token for a v4.local token — they are syntactically distinct, and library APIs reject the wrong one at the call site. There is no alg header inside the payload to parse, trust, or rewrite. The version prefix cryptographically commits to the algorithm because the algorithm is what produces the signature or ciphertext — changing the prefix without changing the bytes makes verification fail.

PASETO is not an IETF standard the way JWT (RFC 7519) is. It is a maintained specification with reference implementations and test vectors, used in production by teams that want JWT's shape without JWT's history.

Versioned protocol: v3 (NIST) vs v4 (modern crypto)

PASETO is versioned at the protocol level — not via a negotiable field, but via a fixed prefix. Two versions are current; two are deprecated.

VersionStatusLocal (symmetric)Public (asymmetric)When to pick
v1DeprecatedAES-256-CTR + HMAC-SHA384RSA-PSS 2048Legacy interop only
v2DeprecatedXChaCha20-Poly1305Ed25519Legacy interop only
v3CurrentAES-256-CTR + HMAC-SHA384 (EtM)ECDSA P-384FIPS-140 / NIST compliance required
v4CurrentXChaCha20-Poly1305Ed25519Default for new projects without NIST constraints

v3 exists for environments where every cryptographic primitive must be on the NIST shortlist: US federal work, FIPS-140 validated modules, government contractors, regulated finance. AES-256-CTR with HMAC-SHA384 is encrypt-then-MAC (EtM) — the MAC covers the ciphertext, version, purpose, and footer, so any tampering breaks verification. ECDSA over P-384 is the corresponding asymmetric primitive.

v4 is the modern default. XChaCha20-Poly1305 is an AEAD construction with a 192-bit nonce — large enough that random nonces are safe without coordination. Ed25519 signatures are 64 bytes, deterministic, and faster than ECDSA P-384 on every platform that benchmarks public-key crypto. v4 is what you should pick unless you have a specific compliance constraint that forces v3.

Versions are not negotiable per token — a service decides which versions it accepts and rejects everything else. Most production deployments accept exactly one version (v4.public for service-to-service, say), which keeps the verifier configuration dead simple.

Local (symmetric) vs Public (asymmetric) purposes

PASETO splits tokens into two purposes at the protocol level. The purpose is the second segment of the token (local or public) and cannot be confused with the other because the library API is different — you cannot call verify on a local token by mistake.

local tokens are encrypted with a shared symmetric key. Both issuer and verifier hold the same secret. Used for: session tokens where one service issues and verifies, encrypted-at-rest tokens for cookie storage, internal service-to-service auth where key distribution is solved out-of-band. The payload is opaque — the holder cannot read the claims without the key.

public tokens are signed with an asymmetric keypair. The issuer holds the secret key; any number of verifiers hold the public key. Used for: OIDC ID-token-style flows (one issuer, many verifiers), signed URLs where the URL is public, federated authentication. The payload is readable by anyone who decodes base64 — encryption is not the goal, integrity is.

The purpose split fixes a JWT pain point: in JWT, HS256 (symmetric) and RS256 (asymmetric) are both signatures, both selected via the same alg header field, and both verified by the same jwt.verify API. That is the foundation of the HS256/RS256 confusion attack. In PASETO, v4.local and v4.public are different functions on different keys with different signatures. You cannot accidentally verify a public token with a symmetric secret because the library API for local takes a 32-byte symmetric key and the API for public takes an Ed25519 public key — different types, caught at compile or call time.

// PASETO v4 — local (symmetric)
import { V4 } from 'paseto'

const symmetricKey = await V4.generateKey('local')
const token = await V4.encrypt({ sub: 'user_123' }, symmetricKey)
//    v4.local.b9kSn7nMa5JtkqEm...

const claims = await V4.decrypt(token, symmetricKey)

// PASETO v4 — public (asymmetric)
const { secretKey, publicKey } = await V4.generateKey('public')
const signed = await V4.sign({ sub: 'user_123' }, secretKey)
//    v4.public.eyJzdWIiOiJ1c2VyXzEyMyI...

const verified = await V4.verify(signed, publicKey)

PASETO payload structure (footer, claims, version)

The payload of a PASETO token is a JSON object — the same claim shape as JWT. Standard claims (iss, sub, aud, exp, nbf, iat, jti) carry the same semantics they do in RFC 7519, so anyone fluent in JWT claims is immediately productive in PASETO. The difference is in how the payload is wrapped, not what it contains.

// The claims object inside a PASETO token
{
  "iss": "https://auth.example.com",
  "sub": "user_123",
  "aud": "https://api.example.com",
  "exp": "2026-05-24T00:00:00+00:00",
  "iat": "2026-05-23T00:00:00+00:00",
  "jti": "01HKQX..."
}

The footer is the optional fourth segment. It is base64-encoded but not encrypted — it is plaintext metadata that is still authenticated (the MAC or signature covers it). Typical footer contents: a key ID (kid) for key rotation, a routing hint for multi-tenant verifiers, or a wrapped key for key-wrap patterns.

v4.public.eyJzdWIiOiJ1c2VyXzEyMyJ9SIG.eyJraWQiOiJrZXktMjAyNi0wNSJ9
// payload: { sub: "user_123" }
// footer:  { kid: "key-2026-05" } — authenticated, not encrypted

Footers solve PASETO's equivalent of the JWT kid header problem without the same security pitfalls. Because the footer is authenticated, an attacker cannot swap the kid to redirect verification to a different key — any modification of the footer bytes breaks the MAC or signature. The verifier reads the kid, picks the corresponding key from a local map, and verifies; if the kid points to nothing, verification fails fast.

The full canonical structure: h.m.b64(payload).b64(f) where h is the version (v3 or v4), m is the purpose (local or public), payload is the encrypted ciphertext or signed body, and f is the optional footer. The dots are literal separators.

JWT vs PASETO: side-by-side specification comparison

AspectJWT (RFC 7519)PASETO (v3/v4)
Algorithm locationIn token header (alg field)In version prefix (fixed per version)
Algorithm choicesHS256/384/512, RS256/384/512, ES256/384/512, PS256/384/512, EdDSA, nonev3: AES-CTR-HMAC / ECDSA P-384. v4: XChaCha20-Poly1305 / Ed25519. No other choices.
alg: none attack possible?Yes (default in many libs prior to mitigation)No (no none variant exists)
HS/RS confusion possible?Yes (if verifier trusts header alg)No (local and public are different APIs on different key types)
Encrypted variantJWE (separate spec, different shape: 5 segments)local purpose (same protocol, distinguished by prefix)
Signed variantJWS (3 segments: header.payload.signature)public purpose
Token shapeheader.payload.signature (3 base64url segments)version.purpose.payload[.footer] (3 or 4 segments)
Standard library APIjwt.sign(payload, key, { algorithm })V4.sign(payload, key) — no algorithm param
Key typesHMAC secret, RSA key, EC key — selected by algEd25519 key, ECDSA P-384 key, 32-byte symmetric — fixed per version/purpose
Public-key signature sizeRS256: 256 bytes / Ed25519: 64 bytesv3.public (P-384): 96 bytes / v4.public (Ed25519): 64 bytes
Claims formatJSON object (same standard claim names)JSON object (same standard claim names)
StandardizationIETF RFC 7519 + JOSE familyPASETO specification (paseto.io) with test vectors
OIDC/OAuth ecosystemYes (the default)No (no OIDC providers issue PASETO natively)

The table makes the tradeoff visible. PASETO wins on safety-by-default — entire vulnerability classes do not exist in the protocol. JWT wins on ecosystem — every identity provider, every gateway, every language has JWT support out of the box. The right pick depends on whether your tokens cross the OIDC/OAuth boundary or stay inside infrastructure you control. For service-to-service auth, signed URLs, and session tokens, PASETO is strictly easier to deploy safely. For federated identity, JWT remains the practical answer. See also JWT signing algorithms for the algorithm-choice problem PASETO sidesteps.

Migration guide: drop-in replacement for JWT in Node.js, Python, Go

Migration is mechanical when you control both sides of the token. The strategy is dual-acceptance during rollout: verifiers accept both JWT and PASETO, issuers switch from JWT to PASETO, and once all in-flight JWT tokens have expired, JWT support is removed. Format detection is trivial because the prefixes are syntactically distinct.

// Detect format from the prefix
function detectTokenFormat(token: string): 'jwt' | 'paseto' | 'unknown' {
  if (token.startsWith('v3.public.') || token.startsWith('v3.local.')) return 'paseto'
  if (token.startsWith('v4.public.') || token.startsWith('v4.local.')) return 'paseto'
  if (token.startsWith('eyJ')) return 'jwt'  // base64 of {"alg":...
  return 'unknown'
}

Node.js — paseto library:

import { V4 } from 'paseto'
import jwt from 'jsonwebtoken'

// Before (JWT)
const jwtToken = jwt.sign({ sub: 'user_123' }, secret, { algorithm: 'RS256' })
const jwtClaims = jwt.verify(jwtToken, publicKey, { algorithms: ['RS256'] })

// After (PASETO v4.public)
const pasetoKeys = await V4.generateKey('public')
const pasetoToken = await V4.sign({ sub: 'user_123' }, pasetoKeys.secretKey)
const pasetoClaims = await V4.verify(pasetoToken, pasetoKeys.publicKey)

Python — pyseto library:

import pyseto
from pyseto import Key

# Sign (v4.public)
secret = Key.new(version=4, purpose='public', key=ed25519_secret_bytes)
token = pyseto.encode(secret, {'sub': 'user_123', 'exp': '2026-05-24T00:00:00Z'})

# Verify
public = Key.new(version=4, purpose='public', key=ed25519_public_bytes)
decoded = pyseto.decode(public, token)
# decoded.payload is a dict of claims

Go — aidanwoods.dev/go-paseto:

import paseto "aidanwoods.dev/go-paseto"

// Sign (v4.public)
secretKey := paseto.NewV4AsymmetricSecretKey()
publicKey := secretKey.Public()

token := paseto.NewToken()
token.SetIssuer("auth.example.com")
token.SetSubject("user_123")
token.SetExpiration(time.Now().Add(time.Hour))

signed := token.V4Sign(secretKey, nil)
// signed is "v4.public.eyJzdWIi..."

// Verify
parser := paseto.NewParser()
verified, err := parser.ParseV4Public(publicKey, signed, nil)

The library landscape also includes paseto-rust (rusty_paseto in Cargo), paseto-node (the paseto and paseto-ts packages on npm), paseto-py (pyseto on PyPI), and paseto-go (multiple maintained forks). All ship v3 and v4 with test vectors that exactly match the reference vectors at paseto.io — implementations are conformant or they fail the test suite. For algorithm-pinning patterns that protect existing JWT code, see JWT best practices.

When PASETO is overkill (and when JWT is dangerous)

PASETO is overkill when: you are integrating with OIDC providers, OAuth2 authorization servers, or third-party APIs that speak JWT — there is no way to make them speak PASETO. Auth0, Okta, Cognito, Google Identity, Azure AD, Keycloak, and every major IdP issues JWT. If your token crosses that boundary, JWT is the only choice. Forcing PASETO into a JWT-shaped ecosystem creates translation layers that introduce more bugs than the security wins justify.

PASETO is also overkill when: you have a JWT codebase that pins the algorithm in the verifier (algorithms: ['RS256']), rejects alg: none, validates exp/nbf correctly, and uses a well-maintained library. Those mitigations exist and they work — the JWT attack surface only matters if you skip the mitigations. Migrating a hardened JWT deployment to PASETO is rarely worth the operator-familiarity cost.

JWT is dangerous when: the verifier reads alg from the token header without an allowlist; when a library default does this for you; when you support multiple algorithms on the same verifier; when you copy-paste snippets that pass the alg through without validation; when token issuance and verification live in different services maintained by different teams with different security postures. Any of those patterns is a CVE waiting to happen, and they are common.

The honest decision rule:

  • Federated identity, OIDC, third-party API tokens → JWT (no choice).
  • Service-to-service auth, internal session tokens, signed URLs, where you own both sides → PASETO (strictly safer defaults).
  • You have a hardened JWT codebase that works and a team that understands the footguns → stay on JWT.
  • You are starting a new project with no JWT legacy, no OIDC integration → PASETO v4 by default.
  • FIPS-140 / NIST-only crypto required → PASETO v3 (the only modern token format with NIST primitives baked in).

For the JWT side of this conversation, see How JWT works for the protocol, Decode JWT for inspection, JWT vs sessions for the stateless tradeoff, and JWEfor the encrypted JWT variant that PASETO's local purpose replaces.

Key terms

PASETO
Platform-Agnostic SEcurity TOkens — a versioned token format designed as a safer JWT replacement. Algorithm is fixed per version; no alg header; symmetric and asymmetric tokens are syntactically distinct.
version (PASETO)
The protocol-level algorithm selector encoded in the first segment of the token. Current versions are v3 (NIST primitives) and v4 (XChaCha20 / Ed25519). Versions v1 and v2 are deprecated.
purpose (PASETO)
The second segment of a PASETO token. local means symmetric encryption (shared key); public means asymmetric signing (keypair). The split prevents key-confusion attacks at the API layer.
footer (PASETO)
An optional fourth segment carrying unencrypted but authenticated metadata — typically a key ID or routing hint. The MAC or signature covers the footer, so tampering breaks verification.
alg confusion (JWT)
A vulnerability family where the verifier reads the algorithm from the token header and acts on it without an allowlist, letting an attacker downgrade to none or switch from RS256 to HS256 using the public key as an HMAC secret.
local key
A 32-byte symmetric secret used for v4.local (XChaCha20-Poly1305) or v3.local (AES-256-CTR + HMAC-SHA384) tokens. Both issuer and verifier hold the same key.
public keypair
An asymmetric keypair (Ed25519 for v4, ECDSA P-384 for v3) used for public tokens. The issuer holds the secret; any number of verifiers hold the public key.

Frequently asked questions

What is PASETO and how is it different from JWT?

PASETO (Platform-Agnostic SEcurity TOkens) is a token format designed as a safer replacement for JWT. The headline difference is that PASETO bakes the cryptographic algorithm into the version prefix instead of putting it in a parseable header. A token starts with v4.public. or v4.local. — those four characters tell every parser exactly which algorithm to use, and the algorithm cannot be changed by the attacker. JWT, in contrast, encodes the algorithm in a Base64-encoded header (alg: HS256, alg: RS256, even alg: none) that the verifier reads from the token itself, which is the source of the entire alg-confusion attack family. PASETO also fixes secondary problems: no NULL/none algorithm exists, public-key and symmetric tokens are syntactically distinct (you cannot pass one where the other is expected), and the payload is always authenticated even in the encrypted variant. The token shape is similar — base64-ish, dot-separated — but the security posture is fundamentally different.

Is PASETO better than JWT?

PASETO is safer by design — most JWT footguns simply do not exist in PASETO. The alg-confusion attack is impossible because the algorithm is in the version prefix, not in a parseable header. The alg: none attack is impossible because there is no none variant. Key confusion between symmetric and asymmetric is prevented by the local vs public split. For a brand-new project where you control both sides and library support is adequate for your stack, PASETO is the better default. The honest tradeoff is ecosystem maturity: JWT has libraries in every language, hardware token support, OIDC integration, and decades of operator familiarity. PASETO has solid libraries in major languages but a much smaller ecosystem. If you use JWT carefully — pin the algorithm in the verifier, never trust the header alg, reject none — JWT is fine. If you keep getting bitten by the same JWT issues, PASETO removes the footguns at the protocol layer.

Does PASETO support algorithms like RS256?

No, and that is intentional. PASETO does not let you pick the algorithm — the version dictates it. v4.public uses Ed25519 (a modern EdDSA curve), and v4.local uses XChaCha20-Poly1305 (authenticated encryption with extended nonce). v3.public uses ECDSA over NIST P-384, and v3.local uses AES-256-CTR + HMAC-SHA384. There is no v4.public-RS256 or v3.public-RS512. The design rationale is that algorithm selection is one of the largest sources of cryptographic mistakes in TLS, OAuth, and JWT — removing the choice prevents whole categories of attacks. If you need RSA signatures specifically for compatibility with an existing PKI or hardware module, PASETO is not a fit and JWT with explicit algorithm pinning is the right answer. For greenfield work, Ed25519 is faster and produces shorter signatures than RS256, so the algorithm restriction is rarely a real loss.

Can I migrate from JWT to PASETO incrementally?

Yes, and incremental migration is the recommended approach. The simplest pattern is dual-acceptance during the rollout: your verifier inspects the token prefix and routes to either the JWT verifier or the PASETO verifier based on what it sees. PASETO tokens start with v3. or v4. followed by .public. or .local. — JWT tokens start with eyJ (the Base64 of {"alg":...). A small dispatch function handles both for the duration of the rollout. Issuers switch first; verifiers accept both formats; clients gradually request PASETO tokens; once issued JWT lifetimes have all expired, drop JWT support. For OAuth/OIDC flows where the token format is dictated by the spec, the path is harder — you can use PASETO internally (service-to-service tokens) while still issuing JWT at the OIDC boundary. The two formats coexist cleanly because they are syntactically distinguishable on the wire.

What's the difference between PASETO v3 and v4?

v3 and v4 are both current, supported PASETO versions — the choice depends on compliance and ecosystem requirements. v3 uses NIST-approved primitives: AES-256-CTR with HMAC-SHA384 for v3.local (encrypt-then-MAC, EtM) and ECDSA over P-384 for v3.public. It is the right pick for FIPS-140-compliant environments, US government work, or anywhere the cryptographic algorithms must come from the NIST shortlist. v4 uses modern, non-NIST primitives: XChaCha20-Poly1305 for v4.local (authenticated encryption with a 192-bit nonce, no separate MAC needed) and Ed25519 for v4.public. v4 is faster, has shorter signatures, has a wider safety margin against side-channel attacks, and is the recommended default for new projects without compliance constraints. v1 and v2 still exist for legacy interop but are deprecated — do not pick them for new work.

Does PASETO solve the 'alg: none' problem?

Yes, completely. The alg: none vulnerability in JWT comes from the fact that JWT lets the token itself declare its own algorithm in a parseable header — including none, which means no signature. If a verifier trusts the header alg without explicit allowlisting, an attacker can forge a token with alg: none and an empty signature and the verifier accepts it. PASETO has no equivalent. There is no none algorithm. There is no algorithm field in the payload or footer at all. The version prefix (v3 or v4) is part of the token, and it cryptographically commits to a specific algorithm — changing the prefix means the signature or MAC no longer verifies because the algorithm changed. The verifier hardcodes which versions it accepts. An attacker cannot downgrade a v4.public token to a hypothetical v0.none token because no such version exists in the protocol.

Is PASETO widely supported?

PASETO has solid library coverage for major languages but a smaller ecosystem than JWT. There are maintained implementations in Node.js (paseto, paseto-ts), Python (pyseto, paseto), Go (paseto.io/v2, o1egl/paseto), Rust (rusty_paseto, paseto-rust), Java, .NET, Ruby, PHP, Elixir, and Swift. The PASETO website maintains a directory of conformant libraries with test vectors. What PASETO lacks is integration into the larger identity ecosystem: OIDC, OAuth2 authorization server software, API gateways, and identity providers all speak JWT, and most do not have first-class PASETO support. For service-to-service tokens, session tokens, signed URLs, and internal API authentication — anywhere you control both sides — PASETO is well supported. For federated identity, third-party API tokens, and anywhere you need to interoperate with an existing ecosystem, JWT remains the practical choice.

Should I use Macaroons, Branca, or PASETO?

PASETO is the closest drop-in replacement for JWT — same shape (claims-based bearer token), same use cases (session tokens, signed URLs, service-to-service auth). Pick PASETO if you want a JWT-like token without the JWT footguns. Branca is also a JWT replacement but with a different design: a fixed encrypted token using XChaCha20-Poly1305, no version negotiation, no public-key variant. Pick Branca when you only need symmetric encrypted tokens and want the simplest possible format. Macaroons are a different animal: they support delegated authority through caveats — a token can be attenuated by the holder to reduce its scope without contacting the issuer. Pick Macaroons when delegation is the core requirement, not when you want a JWT replacement. For most teams asking the question, PASETO is the answer; Branca and Macaroons are specialized choices for narrower use cases.

Further reading and primary sources