JWT vs PASETO v2 vs TECTO: Choosing the Right Token Protocol in 2026
Source: Dev.to
Tokens are everywhere in modern auth flows. But not all tokens are created equal.
In this post we compare three approaches side‑by‑side — classic JWT (HS256), the more modern PASETO v2, and the brand‑new TECTO — across security, ergonomics, and real‑code examples.
Quick Comparison Table
| Property | JWT (HS256) | PASETO v2 | TECTO |
|---|---|---|---|
| Payload visible? | ✅ Yes (base64) | ✅ Yes (signed, not encrypted) | ❌ Fully encrypted |
| Cipher | None (HMAC) | Ed25519 (sign) / XChaCha20 (encrypt) | XChaCha20‑Poly1305 |
| Nonce | N/A | 24‑byte per token | 24‑byte CSPRNG per token |
| Key size | Variable | Variable | Exactly 256‑bit (enforced) |
| Tamper detection | HMAC signature | Ed25519 / Poly1305 tag | Poly1305 auth tag |
| Error specificity | Reveals reason | Reveals reason | Generic “Invalid token” |
| Algo‑confusion attacks | ⚠️ Yes (the alg: none problem) | ✅ No | ✅ No |
| Key rotation built‑in | ❌ DIY | ❌ DIY | ✅ Native (kid in token) |
1️⃣ JWT – The Old Faithful
jsonwebtoken is the most widely used token library in Node.js. It’s battle‑tested, has a massive ecosystem, and is dead‑simple to start with.
npm install jsonwebtoken
import jwt from "jsonwebtoken";
const SECRET = "my-secret-key"; // ← This is the problem
// Sign
const token = jwt.sign(
{ userId: 42, role: "admin" },
SECRET,
{ expiresIn: "1h", issuer: "my-app" }
);
console.log(token);
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjQyLCJyb2xlIjoiYWRtaW4iLCJpYXQiOjE3MDAwMDAwMDAsImV4cCI6MTcwMDAwMzYwMCwiaXNzIjoibXktYXBwIn0.SIGNATURE
// Verify
const payload = jwt.verify(token, SECRET) as { userId: number; role: string };
console.log(payload.userId); // 42
Payload is readable
// Decode the middle segment of any JWT
const [, payload] = token.split(".");
const decoded = Buffer.from(payload, "base64url").toString("utf-8");
console.log(decoded);
// {"userId":42,"role":"admin","iat":1700000000,"exp":1700003600,"iss":"my-app"}
Anyone who intercepts the token can read the payload – no key needed. This is by design: JWTs are signed, not encrypted. Many developers don’t realize this at first.
Algorithm‑confusion problem
JWT allows the algorithm to be specified in the header. This led to the infamous alg: none attack where attackers could forge tokens by setting the algorithm to none. Even with modern libraries that block none, HMAC vs RSA confusion attacks are still a real concern if you accept tokens from multiple issuers.
When JWT makes sense
- Public, non‑sensitive payloads (user IDs, roles)
- Integrating with third‑party services that require JWT (OAuth, OIDC)
- Your team already has JWT infrastructure
2️⃣ PASETO – Platform‑Agnostic Security Tokens
PASETO was designed to fix JWT’s foot‑guns. It removes algorithm agility entirely – you pick a version and you get a fixed, well‑chosen algorithm. No alg: none, no confusion attacks.
npm install paseto
import { V2 } from "paseto";
const key = await V2.generateKey("local");
// ---- Encrypt (v2.local) ----
const token = await V2.encrypt(
{ userId: 42, role: "admin" },
key,
{ expiresIn: "1h", issuer: "my-app" }
);
console.log(token);
// v2.local.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
// Decrypt
const payload = await V2.decrypt(token, key);
console.log(payload.userId); // 42
// ---- Sign (v2.public) ----
const { privateKey, publicKey } = await V2.generateKey("public");
// Sign (payload is visible, like JWT)
const signed = await V2.sign({ userId: 42 }, privateKey, { expiresIn: "1h" });
// Verify
const verified = await V2.verify(signed, publicKey);
Highlights
- No algorithm confusion – the version (
v2) pins the algorithm. v2.localencrypts the payload (XChaCha20‑Poly1305).- Clean, modern async API.
- No native key‑rotation story –
kidis not part of the token format; you must manage key versioning yourself. - Error messages can still reveal the failure mode.
- No entropy validation on keys – you can pass a weak key and the library will silently accept it.
3️⃣ TECTO – Transport Encrypted Compact Token Object
TECTO takes a different philosophy: every token is fully encrypted, always. There is no “signed‑but‑readable” mode.
bun add tecto
import {
generateSecureKey,
MemoryKeyStore,
TectoCoder,
InvalidSignatureError,
TokenExpiredError,
assertEntropy,
} from "tecto";
// 1️⃣ Generate a cryptographically secure 256‑bit key
const key = generateSecureKey();
// 2️⃣ Set up the key store
const store = new MemoryKeyStore();
store.addKey("my-key-2026", key);
// 3️⃣ Create a coder
const coder = new TectoCoder(store);
// 4️⃣ Encrypt
const token = coder.encrypt(
{ userId: 42, role: "admin" },
{ expiresIn: "1h", issuer: "my-app" }
);
console.log(token);
// tecto.v1.my-key-2026.base64url_nonce.base64url_ciphertext
// 5️⃣ Decrypt
const payload = coder.decrypt(token);
console.log(payload.userId); // 42
Token format
tecto.v1...
- The kid (key ID) is embedded in the token itself, enabling native key rotation – no extra metadata or headers needed.
// Rotate keys
store.addKey("key-2026-01", oldKey); // old key still decrypts old tokens
store.rotate("key-2026-06", newKey); // new tokens use the new key
store.removeKey("key-2026-01"); // drop old key when it’s no longer needed
Strong key‑entropy checks
// These all throw KeyError
assertEntropy(new Uint8Array(32)); // all zeros
assertEntropy(new Uint8Array(32).fill(0xaa)); // repeating byte
assertEntropy(new Uint8Array(16)); // wrong length
// This is safe
const goodKey = generateSecureKey(); // always high‑entropy, 256‑bit
assertEntropy(goodKey); // ✅ passes
Uniform error handling
try {
coder.decrypt(tamperedToken);
} catch (err) {
if (err instanceof InvalidSignatureError) {
// err.message === "Invalid token"
// You don’t know *why* it failed – that’s intentional.
// Attackers can’t probe the system by watching error messages.
}
if (err instanceof TokenExpiredError) {
// Token is simply expired – safe to tell the client.
}
}
When TECTO shines
- You need full confidentiality for every claim.
- Native key‑rotation without extra plumbing.
- Uniform, non‑informative error messages to thwart probing attacks.
TL;DR
| Feature | JWT (HS256) | PASETO v2 | TECTO |
|---|---|---|---|
| Payload readable? | ✅ (base64) | ✅ (signed) | ❌ (encrypted) |
| Fixed algorithm? | ❌ (alg header) | ✅ (version pins) | ✅ (built‑in) |
| Native key rotation? | ❌ | ❌ | ✅ (kid in token) |
| Strong key‑size enforcement? | ❌ | ❌ | ✅ (256‑bit) |
| Uniform error messages? | ❌ | ❌ | ✅ |
| Best for… | Public, non‑sensitive data; existing JWT ecosystems | Modern apps that want signed tokens + optional encryption | Anything that must stay confidential and benefit from built‑in rotation |
Pick the token format that matches your security and operational requirements – and don’t assume “signed = safe. Encryption matters.
Token Formats Compared
Below is a quick‑look comparison of JWT, PASETO v2, and TECTO.
All three can carry the same payload – e.g. { userId: 42, role: "admin" } – but they differ dramatically in how the data is protected.
Concrete example
// Payload we want to transport
{ userId: 42, role: "admin" }
JWT (signed only)
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJ1c2VySWQiOjQyLCJyb2xlIjoiYWRtaW4ifQ ← base64 of { userId: 42, role: "admin" }
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Anyone can run atob() on the middle segment and read the payload – no secret key is required.
PASETO v2.local (encrypted)
v2.local.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxFULL_CIPHERTEXT
Encrypted with XChaCha20‑Poly1305. Without the key the ciphertext is opaque.
TECTO (encrypted + built‑in key‑id)
tecto.v1.my-key-2026.NONCE_BASE64URL.CIPHERTEXT_BASE64URL
Encrypted with XChaCha20‑Poly1305.
The kid (my‑key‑2026) is visible only as a label; the payload remains unreadable without the key.
Feature Matrix
| Feature | JWT | PASETO v2 | TECTO |
|---|---|---|---|
| Key ID in token | ❌ Not standard | ❌ Not standard | ✅ Built‑in kid |
| Old token decryptable after rotation | DIY | DIY | ✅ store.rotate() handles it |
| Revoke old key | DIY | DIY | ✅ store.removeKey() zeroes memory |
| Entropy validation | ❌ No | ❌ No | ✅ assertEntropy() enforced |
| Tamper‑evidence | ✅ HMAC | ❌ Payload readable by anyone | ✅ Authenticated encryption (XChaCha20‑Poly1305) |
| Algorithm agility | ❌ Algorithm confusion attack surface | ✅ Version pins algorithm | ✅ Version pins algorithm |
| Native key rotation | ❌ No | ❌ No | ✅ With kid |
| Generic errors (no oracle attacks) | ✅ | ✅ | ✅ |
| Timing‑safe comparisons | ✅ | ✅ | ✅ |
| Memory zeroing on key removal | ✅ | ✅ | ✅ |
| Payload size limits (DoS prevention) | ✅ | ✅ | ✅ |
| Type‑checked registered claims | ✅ | ✅ | ✅ |
When to Use Which Token
-
Use JWT if you need:
- Compatibility with OAuth / OIDC / existing infrastructure
- Payloads that contain no sensitive data (just IDs, roles, etc.)
- Integration with third‑party services that only understand JWT
-
Use PASETO v2.local if you want:
- A well‑audited, standardized encrypted token
- Interoperability across many languages / platforms
- No built‑in native key rotation (you’ll handle rotation yourself)
-
Use TECTO if you want:
- Encryption‑by‑default with zero configuration mistakes possible
- Native key rotation without extra infrastructure
- A greenfield TypeScript/Bun project
- Defense‑in‑depth: entropy validation, generic errors, timing safety, memory zeroing, payload size limits, etc.
Quick‑Start with TECTO
bun add tecto
import { generateSecureKey, MemoryKeyStore, TectoCoder } from "tecto";
// 1️⃣ Create a key store and add a key
const store = new MemoryKeyStore();
store.addKey("v1", generateSecureKey());
// 2️⃣ Build a coder that knows about the store
const coder = new TectoCoder(store);
// 🔐 Encrypt a payload (expires in 1 hour)
const token = coder.encrypt({ userId: 42 }, { expiresIn: "1h" });
// 🔓 Decrypt it back (type‑checked)
const { userId } = coder.decrypt(token);
Persistent key storage
TECTO ships with adapters for SQLite, PostgreSQL, and MariaDB – all implement the same KeyStoreAdapter interface, so swapping the backend is trivial.
Bottom Line
- JWT will stay the de‑facto standard for federated auth and OAuth flows – and that’s fine for public, non‑sensitive claims.
- PASETO v2.local gives you a solid, standardized encrypted token, but you must manage rotation and entropy yourself.
- TECTO goes a step further: batteries‑included key rotation, mandatory entropy checks, generic error handling, timing‑safe comparisons, and automatic memory zeroing.
The best token protocol is the one you can’t misconfigure.
TECTO makes a strong case for that.