Skip to content
ArticlesBack EndWeb Security

PASETO vs. JWT: Why Modern Security Architectures Are Replacing JOSE and JSON Web Tokens

15 min readsalahtobok
PASETO vs. JWT: Why Modern Security Architectures Are Replacing JOSE and JSON Web Tokens

The Cryptographic Crisis of JSON Web Tokens

For more than a decade, JSON Web Tokens (JWT) specified in RFC 7519 have served as the default currency for web identity. Microservices, single-page applications, and mobile clients pass JWTs in HTTP authorization headers to communicate user claims. When software architects design stateless authentication systems, JWT is usually the first technology that comes to mind. However, beneath the widespread adoption of JWT lies a history of severe security vulnerabilities, cryptographic footguns, and implementation flaws.

The fundamental problem with JWT does not stem from bad software developers. It stems from bad protocol design. The Javascript Object Signing and Encryption (JOSE) standard allows the token itself to tell the server how to verify its cryptographic validity. By placing algorithm selection inside an unauthenticated header, JWT violated a core rule of cryptography: never allow an untrusted client to dictate your security parameters. This design flaw produced famous security exploits, including the "alg": "none" bypass and public-key algorithm confusion attacks.

To eliminate these hazards, cryptographers created PASETO: Platform-Agnostic Security Tokens. PASETO removes cipher agility completely. A developer cannot accidentally configure a weak encryption algorithm or a broken curve. With PASETO, tokens are either authenticated and encrypted using modern symmetric ciphers (v4.local) or digitally signed using secure Edwards-curve algorithms (v4.public). In this article, we examine why enterprise architectures are migrating to PASETO, analyze how it stops common JWT exploits, and build a reference implementation using Java 25.

Anatomy of a Broken Standard: Cipher Negotiation in JOSE

A standard JSON Web Token consists of three parts separated by periods: a Header, a Payload, and a Signature. The header is a Base64URL-encoded JSON document that specifies metadata about the token. A typical JWT header looks like this:

{
  "alg": "RS256",
  "typ": "JWT"
}

The "alg" field indicates the cryptographic algorithm used to secure the token. In the JOSE specification, supported values include HMAC-SHA256 (HS256), RSA with SHA-256 (RS256), ECDSA (ES256), and even unauthenticated plaintext (none). When a backend server receives a JWT, the verification library parses this header before verifying the signature. The library reads the "alg" field and dynamically selects which cryptographic routine to execute.

This design is known as cipher agility. In theory, cipher agility sounds attractive because it allows protocols to adopt newer algorithms over time. In practice, cipher agility in an unauthenticated header is an architectural catastrophe. Because the header travels in cleartext and is processed prior to authentication, an attacker can manipulate the algorithm field to trick the server into executing insecure verification paths.

Two Catastrophic JWT Vulnerabilities Explained

To appreciate why modern security architectures are abandoning JOSE, let us dissect two well-documented exploits that affected nearly every major JWT library in existence.

The Cryptographic Flaw in JOSE: Cipher Negotiation and Algorithm Confusion Attacks
Figure 1: Mechanics of the ‘none’ algorithm bypass and public-key to HMAC key confusion exploits.

1. The “none” Algorithm Authentication Bypass

The JOSE specification explicitly included an algorithm named "none" to represent unsecured tokens. This was intended for environments where cryptographic integrity was supposedly handled at another layer. However, many early JWT libraries contained logic that checked:

if ("none".equalsIgnoreCase(tokenHeader.getAlgorithm())) {
    // Skip signature check; payload is considered valid!
    return parsePayload(tokenBody);
}

Attackers exploited this immediately. An attacker captured a legitimate JWT, decoded the Base64URL header, modified "alg" to "none", altered the payload to claim administrator privileges ("role": "SUPERADMIN"), and stripped the signature bytes completely. When sent to vulnerable servers, the library saw "none", skipped cryptographic validation, and granted full administrative access. Developers had to write custom defensive boilerplate to explicitly ban the "none" algorithm across their codebases.

2. The Public-Key to HMAC Algorithm Confusion Attack

Even more dangerous is the algorithm confusion exploit (often designated CVE-2016-5431). Consider an enterprise system that uses asymmetric RSA signatures (RS256). The authorization server holds an RSA private key to sign tokens, while resource servers hold the corresponding RSA public key to verify signatures. Because public keys are not secrets, the RSA public key is often hosted at a public JWKS endpoint (such as /.well-known/jwks.json).

An attacker downloads the server’s public key (a standard PEM file). The attacker then generates a forged JWT payload with elevated permissions. Instead of signing with RSA, the attacker sets the header to "alg": "HS256" (symmetric HMAC-SHA256). The attacker signs the forged token using HMAC-SHA256, but uses the raw byte content of the RSA public key as the HMAC shared secret!

When the vulnerable resource server receives the token, it reads "alg": "HS256". The server’s JWT verifier switches to HMAC verification mode and passes its configured key: which is the RSA public key. Because the attacker signed the token using that exact same public key byte sequence, the HMAC signature calculation matches perfectly! The server authenticates the attacker as an administrator, completely unaware that an asymmetric public key was misused as a symmetric secret.

What is PASETO and Why is it Footgun-Free?

PASETO (Platform-Agnostic Security Tokens) was engineered to fix every systemic weakness found in JWT. Instead of offering dozens of cipher combinations and trusting headers, PASETO establishes two core design principles:

  1. Zero Algorithm Negotiation: The token format does not contain an "alg" field. The algorithm is strictly bound to the PASETO version and purpose. You cannot switch ciphers dynamically.
  2. Strong, Opinionated Cryptography: Each version of PASETO specifies one, and only one, cryptographic suite vetted by modern cryptographers.

A PASETO token is a simple string with three or four parts separated by periods:

<version>.<purpose>.<payload>[.<footer>]

Currently, PASETO Version 4 is the recommended modern standard. In Version 4:

  • v4.local: Uses XChaCha20-Poly1305 (or ChaCha20-Poly1305 with BLAKE2b subkey derivation) for authenticated symmetric encryption. The payload is encrypted; outside observers cannot read the claims.
  • v4.public: Uses Ed25519 (RFC 8032) for asymmetric digital signatures. The payload is signed and readable, but cryptographically tamper-proof.

Because v4.public strictly requires an Ed25519 key, and v4.local strictly requires a 32-byte symmetric key, an algorithm confusion attack is mathematically impossible. A developer cannot accidentally sign a local token with an RSA key or verify a public token with an HMAC secret.

Pre-Authentication Encoding: Defeating Canonicalization Attacks

Beyond cipher confusion, traditional token standards suffer from parser and canonicalization vulnerabilities. When cryptographic signatures sign concatenated strings like header + "." + payload, attackers can sometimes exploit ambiguity in how different programming languages decode Base64 strings or parse JSON properties.

PASETO Version 4 Anatomy: Header, Payload, and Pre-Authentication Encoding (PAE)
Figure 2: Length-prefixed Pre-Authentication Encoding formula preventing canonicalization and delimiter collisions.

PASETO completely eliminates parser confusion by enforcing Pre-Authentication Encoding (PAE). Before any data is hashed, signed, or encrypted, PASETO passes all components through the PAE function. PAE prepends each piece with its exact length encoded as a 64-bit Little-Endian integer (LE64):

PAE(A, B, C) = LE64(3) || LE64(|A|) || A || LE64(|B|) || B || LE64(|C|) || C

Because every data segment is explicitly length-delimited before hashing, an attacker cannot shift characters across segment boundaries. If an attacker modifies a single byte in the header or payload, the length headers invalidate the signature immediately. PAE provides mathematical proof that token pieces cannot collide or be misinterpreted during verification.

PASETO v4.local vs v4.public Tokens

PASETO provides two distinct operating modes tailored for different architectural scenarios in modern distributed systems.

PASETO Dual Architecture: v4.local Authenticated Encryption vs v4.public Ed25519 Signatures
Figure 3: Symmetric ChaCha20-Poly1305 encryption flow compared to asymmetric Ed25519 digital signatures.

1. PASETO v4.local (Authenticated Encryption)

In standard JWT, the payload is only Base64URL-encoded, not encrypted. Anyone who intercepts a JWT can read user IDs, email addresses, and tenant identifiers. Many developers mistakenly store sensitive data (like database schema IDs or user permissions) inside JWTs without realizing they are leaking confidential data over the wire.

v4.local solves this by providing authenticated encryption with associated data (AEAD). The payload is encrypted using a 256-bit symmetric secret key. Outside clients, browsers, and proxies see only random ciphertext. Only backend services possessing the secret key can decrypt the token. Furthermore, the Poly1305 authentication tag guarantees that the ciphertext cannot be modified or reordered in transit. This makes v4.local ideal for internal microservice session tokens and stateful cookies.

2. PASETO v4.public (Asymmetric Digital Signatures)

When you build public APIs, OAuth2 authorization servers, or single sign-on (SSO) systems, resource servers must verify tokens without knowing the private signing secret. v4.public uses Ed25519 public-key cryptography. The authorization server signs the claims using its Ed25519 private key. Downstream API gateways and external clients verify the token using the author’s public key.

Unlike RSA (which uses large 2048-bit or 4096-bit keys and suffers from Bleichenbacher timing attacks), Ed25519 keys are compact (32 bytes), signatures are fixed size (64 bytes), and signature generation is deterministic and constant-time. This provides superior CPU performance on high-throughput API gateways.

Security Comparison Matrix: PASETO vs JWT

The comparative matrix below outlines the critical architectural differences between legacy JOSE/JWT and modern PASETO Version 4:

Security Dimension JSON Web Tokens (JWT / JOSE) PASETO Version 4
Cipher Negotiation Dynamic (Header dictates algorithm) Forbidden (Fixed by version/purpose)
“none” Algorithm Exploit Possible unless defensively blocked Impossible by protocol specification
Key Confusion Attack Vulnerable (RSA public key used as HMAC) Impossible (Strict key-type binding)
Asymmetric Algorithm RSA / ECDSA (Variable curves & padding) Ed25519 (RFC 8032, fast & constant-time)
Symmetric Encryption Complex JWE (Dozens of cipher suites) ChaCha20-Poly1305 (Authenticated AEAD)
Canonicalization Defense None (Fragile Base64 parsing) PAE (Pre-Authentication Encoding)
Default Payload Privacy Plaintext (Visible to clients & proxies) Encrypted in local mode (Confidential)
Developer Error Surface High (Requires extensive security audits) Minimal (Opinionated, footgun-free API)

Practical Java 25 Implementation Guide

To demonstrate how easy it is to implement PASETO without the baggage of heavy frameworks, our companion workshop codebase implements a clean, production-grade PASETO V4 engine in Java 25 using standard Java Cryptography Architecture and Bouncy Castle for Edwards-curve signatures.

First, we define our domain claims model as an immutable Java 25 record. Defensive copies and validation preconditions protect against clock skew and null references:

package com.webcodein.workshop.paseto.model;

import java.time.Instant;
import java.util.Collections;
import java.util.Map;
import java.util.Objects;

public record TokenClaims(
    String subject,
    String issuer,
    String audience,
    Instant issuedAt,
    Instant expiresAt,
    Map<String, Object> customClaims
) {
    public TokenClaims {
        Objects.requireNonNull(subject, "Subject must not be null");
        Objects.requireNonNull(issuer, "Issuer must not be null");
        Objects.requireNonNull(issuedAt, "IssuedAt must not be null");
        Objects.requireNonNull(expiresAt, "ExpiresAt must not be null");
        if (expiresAt.isBefore(issuedAt)) {
            throw new IllegalArgumentException("ExpiresAt cannot be earlier than IssuedAt");
        }
        customClaims = customClaims != null ? Collections.unmodifiableMap(Map.copyOf(customClaims)) : Map.of();
    }

    public boolean isExpired(Instant currentInstant) {
        return currentInstant.isAfter(expiresAt);
    }
}

Next, let us review the Pre-Authentication Encoding (PAE) logic. PAE guarantees that each segment is length-encoded using 64-bit Little-Endian integers before signing or encryption:

public static byte[] preAuthEncode(byte[]... pieces) {
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    out.writeBytes(le64(pieces.length));
    for (byte[] piece : pieces) {
        out.writeBytes(le64(piece.length));
        out.writeBytes(piece);
    }
    return out.toByteArray();
}

private static byte[] le64(long n) {
    ByteBuffer buffer = ByteBuffer.allocate(8);
    buffer.order(ByteOrder.LITTLE_ENDIAN);
    buffer.putLong(n);
    return buffer.array();
}

Finally, observe how PasetoV4SecurityEngine handles v4.public signing and verification using Ed25519. Notice that there is no algorithm header checking: the method takes an Ed25519PublicKeyParameters key and verifies the exact 64-byte trailing signature:

public TokenValidationResult verifyPublicToken(String token, Ed25519PublicKeyParameters publicKey) {
    if (token == null || !token.startsWith("v4.public.")) {
        return TokenValidationResult.failure("INVALID_HEADER", "Token header mismatch; expected v4.public.");
    }

    try {
        String b64Body = token.substring("v4.public.".length());
        byte[] body = Base64.getUrlDecoder().decode(b64Body);

        if (body.length < 64) {
            return TokenValidationResult.failure("INVALID_PAYLOAD", "Payload too short for Ed25519 signature");
        }

        int payloadLen = body.length - 64;
        byte[] payloadBytes = new byte[payloadLen];
        byte[] signature = new byte[64];
        System.arraycopy(body, 0, payloadBytes, 0, payloadLen);
        System.arraycopy(body, payloadLen, signature, 0, 64);

        byte[] headerBytes = "v4.public.".getBytes(StandardCharsets.UTF_8);
        byte[] pae = preAuthEncode(headerBytes, payloadBytes, new byte[0]);

        Ed25519Signer verifier = new Ed25519Signer();
        verifier.init(false, publicKey);
        verifier.update(pae, 0, pae.length);

        if (!verifier.verifySignature(signature)) {
            return TokenValidationResult.failure("SIGNATURE_VERIFICATION_FAILED", "Ed25519 signature invalid");
        }

        TokenClaims claims = objectMapper.readValue(payloadBytes, TokenClaims.class);
        if (claims.isExpired(Instant.now())) {
            return TokenValidationResult.failure("TOKEN_EXPIRED", "Token has expired");
        }

        return TokenValidationResult.success(claims);
    } catch (Exception e) {
        return TokenValidationResult.failure("DECODE_ERROR", e.getMessage());
    }
}

Because the signature verification logic accepts only Ed25519 keys, it is impossible for an attacker to supply an HMAC token or a forged RSA payload. The code is secure by construction.

Real-World Enterprise Case Studies

Case Study 1: Cloud-Native Microservices Migration to v4.local

A European banking platform previously passed plaintext JWTs between internal microservices. During a penetration test, security researchers discovered that intermediate proxy access logs were recording customer tax IDs and account balances transmitted inside JWT payload claims. The engineering team migrated internal inter-service auth to PASETO v4.local. By encrypting payloads with ChaCha20-Poly1305, claims became unreadable to middlebox logging systems, satisfying strict GDPR compliance requirements without requiring expensive database lookup calls.

Case Study 2: High-Throughput IoT Gateway Authentication

An industrial IoT gateway processed 150,000 device heartbeats per second. The devices originally used RSA 2048-bit signed JWTs. Verification of RSA signatures consumed 68% of the gateway cluster CPU capacity. After transitioning device firmware and backend ingestion services to PASETO v4.public with Ed25519, cryptographic verification throughput increased by 350%. Gateway CPU utilization dropped to 22%, allowing the enterprise to decommission half of its verification instances.

Case Study 3: Eliminating Algorithm Confusion in Zero-Trust Architectures

A multi-tenant SaaS provider maintained an external API where third-party partners presented tokens signed by partner IdPs. The legacy JWT verifier accidentally allowed partners to submit HMAC tokens using public keys, leading to a critical severity finding during a SOC 2 Type II audit. The provider deprecated JWT and implemented PASETO v4.public. Because PASETO enforces strict cryptographic binding, the vulnerability was completely eradicated at the protocol layer.

Production Anti-Patterns and Gotchas

Anti-Pattern 1: Leaking Unencrypted Sensitive Data in Public Tokens

Remember that PASETO v4.public tokens are digitally signed, not encrypted. If you store sensitive personally identifiable information (PII) inside a v4.public token, any client or browser can decode the Base64URL string and read the payload. Use v4.public strictly for identity claims and scopes (e.g. sub, roles, exp). When sensitive session secrets must travel through untrusted networks, use v4.local.

Anti-Pattern 2: Reusing Nonces in Symmetric Local Mode

In authenticated symmetric encryption (ChaCha20-Poly1305), reusing a cryptographic nonce with the same secret key destroys security. If two distinct tokens are encrypted with the same key and nonce, an attacker can perform XOR differential cryptanalysis to recover plaintext data. Always generate nonces using a Cryptographically Secure Pseudo-Random Number Generator (such as java.security.SecureRandom) and ensure nonces are freshly generated for every issued token.

Anti-Pattern 3: Skipping Footers During Key Rotation

PASETO supports an optional unencrypted footer (the fourth period-separated segment). The footer is intended for key identification metadata (such as a Key ID kid). The footer is authenticated via PAE, meaning an attacker cannot tamper with the Key ID. Failing to utilize the footer during key rotation often leads developers to try parsing claims before key selection, reintroducing insecure code patterns. Always place your kid in the footer and pass it to your key resolver.

Frequently Asked Questions

Can PASETO completely replace JWT in modern web architectures?

Yes. PASETO provides all the capabilities of JWT (stateless claims, expiry timestamps, digital signatures, and encryption) while eliminating algorithm negotiation and insecure cipher options. Modern identity providers and API gateways increasingly support PASETO as a secure replacement for JOSE.

Why does PASETO forbid cipher negotiation?

Cipher negotiation in token headers allows attackers to dictate the verification algorithm. This led to famous vulnerabilities like the ‘none’ algorithm bypass and public-key confusion attacks. By binding ciphers strictly to the PASETO version (e.g. v4 strictly uses Ed25519 and ChaCha20-Poly1305), PASETO removes the entire class of algorithm substitution attacks.

What is the difference between PASETO v4.local and v4.public?

v4.local is designed for shared-key environments and provides authenticated symmetric encryption using ChaCha20-Poly1305, keeping claims completely confidential. v4.public is designed for public APIs and uses asymmetric Ed25519 digital signatures, allowing any party with the public key to verify claims while keeping the signing key private.

How does Pre-Authentication Encoding (PAE) prevent attacks?

Pre-Authentication Encoding prefixes every data segment with its length encoded as a 64-bit Little-Endian integer before hashing or signing. This prevents canonicalization attacks and delimiter collisions, ensuring that boundaries between headers, payloads, and footers cannot be manipulated by an attacker.

Is Ed25519 faster and safer than RSA 2048?

Yes. Ed25519 offers 128-bit security with compact 32-byte public keys and 64-byte signatures. It operates in constant time, eliminating side-channel timing attacks (such as Bleichenbacher attacks on RSA PKCS#1v1.5), and verifies signatures up to 3 to 4 times faster than 2048-bit RSA on modern CPUs.

Companion Workshop Code

Inspect and run the companion Java 25 workshop project demonstrating PASETO v4 token issuance, encryption, signature verification, and the vulnerable JWT verifier on GitHub:

https://github.com/salahtobok/learn.webcodein.workshop/tree/main/security/paseto-vs-jwt-modern-token-security

Official References and Cryptographic Standards

Comments