Summary
Security is a fundamental aspect of modern web applications built on a client-server architecture. Such applications rely on different approaches to authentication and authorization, including session-based authentication, API keys, OAuth 2.0, and token-based authentication. Among these, token-based approaches have become especially popular for stateless applications due to their scalability and flexibility. This article focuses on stateless token-based authentication and compares two widely used solutions: JWT and PASETO.
Main Takeaways:
- JWT is flexible, with optional signatures, numerous algorithms, and a complex JOSE ecosystem. This provides developers with significant freedom in how to implement token-based authentication, but these same options are prone to developer oversight that can introduce severe security risks.
- Most JWT vulnerabilities stem from implementation or configuration issues rather than flaws in JWT itself. Certain practices, like strict algorithm whitelisting and proper validation, can prevent security risks such as algorithm confusion and missing signatures.
- PASETO dictates security as a default, offering no algorithm selection and fewer cryptographic choices. This leaves less room for developer error and fewer potential security gaps for malicious actors to exploit.
- PASETO is simpler to implement and provides both encryption and signing out of the box: local mode uses symmetric encryption, while public mode uses asymmetric signatures.
- Choosing between the two largely comes down to a choice between compatibility and simplicity. JWT is preferable when broad ecosystem support, OAuth/OIDC, or the ability to use existing infrastructure is important. PASETO, on the other hand, can be a compelling option for new projects where secure-by-default token handling is the priority.
JSON Web Token (JWT)
JSON Web Token (JWT) is an open standard for transferring claims between parties in a compact format. In modern web applications, JWT is widely used to implement authentication and authorization, particularly when building REST APIs and distributed systems.
The main advantage of JWT is that it lets user information travel directly inside the token itself, so the server does not need to store the state of every user session. After a successful authentication, the server issues a token and hands it to the client. The client then presents that token whenever it accesses protected resources.
Structure of a JWT
JWT is a compact, URL-safe format for securely transmitting information between two parties. A JWT consists of three parts: the Header, the Payload, and the Signature, separated by dots:
Header.Payload.Signature
Both the Header and the Payload are JSON objects that are Base64URL-encoded before being combined with the signature.
The Header contains metadata about the token, such as its type and the cryptographic algorithm used to generate the signature:
{
"alg": "HS256",
"typ": "JWT"
}
The Payload contains a set of claims describing the user or the token itself. These claims can include user identifiers, roles, permissions, and token metadata such as the expiration time.
{
"sub": "12345",
"username": "alex",
"role": "admin",
"exp": 1754553600
}
The Signature is generated by signing the encoded Header and Payload using the specified algorithm and a secret key (or a private key for asymmetric algorithms). It allows the recipient to verify that the token has not been modified after it was issued.
Example: Generating a JWT in Python
For example, a JWT can be generated in Python using the PyJWT library:
import jwt
from datetime import datetime, timedelta, timezone
PRIVATE_KEY = open("private.pem").read()
now = datetime.now(timezone.utc)
payload = {
"iss": "https://auth.example.com", # Issuer
"sub": "12345", # Subject / user ID
"aud": "https://api.example.com", # Audience
"exp": now + timedelta(minutes=15), # Expiration Time
"iat": now, # Issued At
"jti": "550e8400-e29b-41d4-a716-446655440000", # JWT ID
"role": "admin",
}
token = jwt.encode(
payload,
PRIVATE_KEY,
algorithm="RS256",
headers={
"kid": "auth-key-2026-01",
},
)
print(token)
Using the Token
Once the client has received the token, it presents it to the server on every protected request, typically via the HTTP Authorization header:
Authorization: Bearer <JWT>
When the server receives the request, it extracts the token and splits it into its three components: the Header, the Payload, and the Signature.
To verify the token’s authenticity, the server does not compare the received signature with a stored value. Instead, it recreates the signature using the received Header and Payload together with the same secret key (or the corresponding private/public key pair for asymmetric algorithms).
For example, for an HMAC-based JWT (HS256), the server performs the equivalent of:
expectedSignature =
HMAC_SHA256(
Base64UrlEncode(Header) + "." + Base64UrlEncode(Payload),
secretKey
)
The generated signature is then compared with the signature included in the token. If the signatures match, the server knows that the token has not been modified since it was issued. It then validates additional claims such as the expiration time (exp), issuer (iss), audience (aud), or any application-specific claims. If all checks succeed, the request is processed on behalf of the authenticated user.
Common Issues with JWT
Let’s now look at the issues that JWT tokens can run into.
Issue #1: No Mandatory Signature
A token can be signed symmetrically or asymmetrically — but, importantly, it can also not be signed at all, by using “alg”: “none”. This effectively lets the developer build weaknesses into authentication with their own hands. This leads to a more serious underlying issue: the JWT specification does not require a signature or encryption at all, and most other claims are optional as well.
Example: the “none” algorithm
# Token with alg: none
header = {"alg": "none", "typ": "JWT"}
payload = {"sub": "admin", "admin": true}
# Attacker modifies the payload
payload = {"sub": "admin", "admin": true, "role": "superadmin"}
# The server accepts the token without a signature!
Issue #2: Flexibility in Choosing Algorithms
The second problem, oddly enough, lies in JWT’s very flexibility: developers can choose their own encryption algorithms, both secure and insecure ones. This again opens the door to opens the door for self-inflicted vulnerability and and putting the security of your data at risk.
Issue #3: Complexity of the Specification
The next problem is the complexity of the JOSE (JavaScript Object Signing and Encryption) family of standards that JWT is built on. Rather than defining only a token format, JOSE consists of several specifications that cover signing, encryption, key representation, and key distribution.
The main JOSE specifications include:
- JWS (JSON Web Signature) – defines how data is digitally signed to guarantee its integrity and authenticity.
- JWE (JSON Web Encryption) – defines how data is encrypted to ensure confidentiality.
- JWK (JSON Web Key) – specifies a standard JSON format for representing cryptographic keys.
- JWA (JSON Web Algorithms) – defines the cryptographic algorithms that can be used for signing and encryption, such as HS256, RS256, ES256, and others.
- JWT (JSON Web Token) – defines the token format itself and relies on the other JOSE specifications for signing or encryption.
While this flexibility allows JWT to support many different use cases, it also makes the ecosystem more difficult to understand and easier to misuse. Developers must choose the correct token type, algorithms, key format, and validation rules. Incorrect choices or insecure configurations have historically led to numerous implementation vulnerabilities.
Case: a simple unsigned token
{
"alg": "none",
"typ": "JWT"
}
- No signature
- No mandatory fields at all
- Fully compliant with RFC 7519
Case: maximal JWS + JWE + JWK
{
"alg": "RS256",
"typ": "JWT",
"kid": "key-id",
"jku": "https://server/keys.json",
"x5u": "https://server/cert.pem",
"iss": "https://auth.example.com",
"sub": "1234567890",
"aud": ["https://api.example.com"],
"exp": 1516239022,
"nbf": 1516239022,
"iat": 1516239022,
"jti": "unique-token-id"
}
- JWS with RS256
- All reserved claims present
- Additional key-identification fields
Both of these tokens are valid.
Issue #4: Algorithm Confusion
Popular JWT libraries can also be affected by the algorithm-confusion vulnerability — a situation where the server mishandles the signing algorithm declared inside the token. For example, an attacker can swap RS256 for HS256 and try to use the public RSA key as an HMAC secret in order to forge a valid-looking token. The defense is to explicitly whitelist the allowed algorithm and enforce the matching key type.
# Vulnerable code (Python, PyJWT)
import jwt
public_key = load_rsa_public_key("server.pem")
# The attacker sends a token with alg: HS256
# and signs it using the public key as an HMAC secret
token = jwt.encode(
{"sub": "admin"},
public_key, # — uses the public key as a secret!
algorithm="HS256"
)
# The server verifies
jwt.decode(token, public_key, algorithms=["RS256", "HS256"])
# — VULNERABILITY: accepts HS256 signed with the public key!
As we can see, none of these problems are inherent to JWT itself, and every one of them can be avoided — but, just as easily, every vulnerability listed above can be introduced through carelessness, lack of knowledge, or simply “vibe-coding” a solution together. PASETO was created specifically to close off that kind of self-inflicted risk.
PASETO (Platform-Agnostic Security Tokens)
PASETO is a standard for secure tokens designed for authentication and for transferring data between a client and a server. Unlike JWT, PASETO restricts the choice of cryptographic algorithms, which reduces the likelihood of misconfiguration and the vulnerabilities that come with it.
PASETO Format
v4.local.eyJ... — encryption (symmetric key)
v4.public.eyJ... — signature (asymmetric key)
Structure
v{version}.{mode}.{body}.{footer?}
- version — v1, v2, v3, v4
- mode — local (encryption) or public (signature)
- footer — optional
PASETO Versions
| Version | Local (encryption) | Public (signature) |
|---|---|---|
| v1 | AES-GCM | RS256, ES256 |
| v2 | AES-GCM | RS256, ES256 |
| v3 | AES-GCM | EdDSA, ECDSA |
| v4 | XChaCha20-Poly1305 | Ed25519 |
Version 4 uses modern algorithms:
- XChaCha20-Poly1305 — AEAD encryption
- Ed25519 — Edwards-curve Digital Signature
PASETO currently has four versions, each tied to a different set of algorithms with a different security margin — but none of them expose an algorithm or signature scheme that is easy to break; every version is cryptographically strong.
PASETO has no “alg” field at all, so there is no place to specify a weak algorithm — or no algorithm at all.
PASETO uses a minimal, fixed configuration: there is no way to stuff secret data into the payload, and no claim can simply be missed.
PASETO is also a remarkably simple specification — it fits on a single README page, in contrast to JWT’s sprawling, multi-layered specification, which runs to 154 pages as a PDF.
PASETO: Local Mode (Encryption)
from jam.paseto.v4 import PASETOv4
import secrets
# Generate a 32-byte secret
secret_key = secrets.token_bytes(32)
# Create a PASETO instance for encryption
paseto = PASETOv4.key("local", secret_key)
# Encode
token = paseto.encode({
"user": "meowl",
"exp": "2030-01-01T00:00:00+00:00"
})
# Decode
payload, _ = paseto.decode(token)
How local mode works
- The header v4.local is formed.
- A 24-byte random nonce is generated (for XChaCha20-Poly1305).
- The AAD (additional authenticated data) is assembled.
- xchacha20poly1305_encrypt(secret, nonce, payload, aad) is called.
- The token is formed as header + base64(nonce + ciphertext).
AEAD combines encryption and authentication in a single step.
During verification: if the data has been tampered with, verification fails with an error.
PASETO: Public Mode (Signature)
from jam.paseto.v4 import PASETOv4
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
# Generate an Ed25519 key pair
private_key = Ed25519PrivateKey.generate()
# Create a PASETO instance for signing
paseto = PASETOv4.key("public", private_key)
# Encode
token = paseto.encode({"user": "mewfish"})
# Decode (requires the public key)
payload, _ = paseto.decode(token, public_key)
How public mode works
- The header v4.public. is formed.
- It is concatenated with the JSON payload.
- pre_auth = PAE([header, payload, footer]) is computed.
- The Ed25519 private key signs pre_auth.
- The resulting signature is 64 bytes long.
During decoding:
- The v4.public. prefix is checked.
- The signature length (64 bytes) is checked.
- The signature is verified against the public key.
Choosing Between JWT and PASETO
Choose PASETO if:
- You need a self-contained token.
- Security “out of the box” matters to you.
- You want to minimize risk.
- You don’t need complex integrations.
Choose JWT if:
- You need broad compatibility.
- You already have supporting infrastructure in place.
- You need OIDC/OAuth2.
In the end, JWT is not a bad or inherently insecure token-based authentication approach. Its main risk is that it hands the developer an enormous amount of freedom, including the freedom to make mistakes. PASETO takes a simpler, more constrained approach, which means many of those potential mistakes simply cannot happen in the first place. So if compatibility matters to you and you already have infrastructure built around JWT, it remains a solid choice. But if you’re starting a new project and want token handling that is as simple and secure as possible by default, PASETO is well worth a look.
If you’re interested, read about our approach to security management, or visit our blog for more articles by SysGears engineers.
