DEV Community

Zastinian
Zastinian

Posted on

JWT vs PASETO v2 vs TECTO: Choosing the Right Token Protocol in 2026

Tokens are everywhere in modern auth flows. But not all tokens are created equal. In this post we'll compare three approaches side by side β€” classic JWTs, the more modern PASETO v2, and the brand-new TECTO β€” across security, ergonomics, and real code.


πŸ” The Quick Summary

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 Industry Standard

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
Enter fullscreen mode Exit fullscreen mode

Creating and verifying a JWT

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
Enter fullscreen mode Exit fullscreen mode

⚠️ The Payload is Just Base64

Here's the catch β€” take the middle segment of any JWT and decode it:

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"}
Enter fullscreen mode Exit fullscreen mode

Anyone who intercepts the token can read the payload. No key needed. This is by design β€” JWTs are signed, not encrypted β€” but many developers don't realize this at first.

⚠️ The alg: none / 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 to use JWT

  • Public, non-sensitive payloads (user IDs, roles)
  • Integrating with third-party services that require JWT (OAuth, OIDC)
  • When your team already has JWT infrastructure

2️⃣ PASETO v2 β€” The JWT Successor

PASETO (Platform-Agnostic Security Tokens) was designed to fix JWT's footguns. 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
Enter fullscreen mode Exit fullscreen mode

Local tokens (symmetric, encrypted)

PASETO v2 comes in two flavors: local (symmetric, encrypted) and public (asymmetric, signed). v2.local is the encrypted one.

import { V2 } from "paseto";

const key = await V2.generateKey("local");

// Encrypt
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
Enter fullscreen mode Exit fullscreen mode

Public tokens (asymmetric, signed but NOT encrypted)

const { privateKey, publicKey } = await V2.generateKey("public");

// Sign (payload is visible, like JWT)
const token = await V2.sign({ userId: 42 }, privateKey, { expiresIn: "1h" });

// Verify
const payload = await V2.verify(token, publicKey);
Enter fullscreen mode Exit fullscreen mode

βœ… What PASETO gets right

  • No algorithm confusion β€” the version (v2) pins the algorithm
  • v2.local encrypts the payload (XChaCha20-Poly1305)
  • Clean, modern API with async support

⚠️ What PASETO still lacks

  • No native key rotation story β€” kid is not part of the token format
  • You must manage key versioning yourself
  • Error messages can still reveal failure mode
  • No entropy validation on keys β€” you can pass a weak key and it'll silently accept it

3️⃣ TECTO β€” Encrypted by Default, Security-First

TECTO (Transport Encrypted Compact Token Object) takes a different philosophy: every token is fully encrypted, always. There's no "signed but readable" mode.

bun add tecto
Enter fullscreen mode Exit fullscreen mode

Creating and decrypting a TECTO token

import { generateSecureKey, MemoryKeyStore, TectoCoder } 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<{ userId: number; role: string }>(token);
console.log(payload.userId); // 42
Enter fullscreen mode Exit fullscreen mode

The token format is self-describing

tecto.v1.<kid>.<nonce>.<ciphertext>
Enter fullscreen mode Exit fullscreen mode

The kid (key ID) is embedded in the token itself. This enables native key rotation β€” no extra metadata or headers needed.

Key rotation is a first-class citizen

// Old key still decrypts old tokens
store.addKey("key-2026-01", oldKey);

// Rotate: new tokens use the new key
store.rotate("key-2026-06", newKey);

// Old tokens still work
const oldPayload = coder.decrypt(oldToken); // βœ… uses key-2026-01

// New tokens use new key
const newToken = coder.encrypt({ userId: 99 });
// tecto.v1.key-2026-06.xxxxx.xxxxx

// Remove old key when all old tokens have expired
store.removeKey("key-2026-01");
Enter fullscreen mode Exit fullscreen mode

Entropy validation on every key

import { assertEntropy } from "tecto";

// 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 key = generateSecureKey(); // always produces a valid, high-entropy key
assertEntropy(key); // βœ… passes
Enter fullscreen mode Exit fullscreen mode

Generic errors prevent oracle attacks

try {
  coder.decrypt(tamperedToken);
} catch (err) {
  if (err instanceof InvalidSignatureError) {
    // err.message === "Invalid token"
    // You don't know WHY it failed β€” and that's the point
    // Attackers can't probe the system by watching error messages
  }
  if (err instanceof TokenExpiredError) {
    // Safe to throw distinctly because we already decrypted successfully
    // err.expiredAt is available, but NOT in err.message (timing protection)
  }
}
Enter fullscreen mode Exit fullscreen mode

πŸ”¬ Side-by-Side: Payload Visibility

Let's make this concrete. Suppose you encode { userId: 42, role: "admin" } in each format:

JWT β€” Fully readable

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJ1c2VySWQiOjQyLCJyb2xlIjoiYWRtaW4ifQ  ← base64 of { userId: 42, role: "admin" }
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Enter fullscreen mode Exit fullscreen mode

Anyone can atob() the middle segment. No key needed.

PASETO v2.local β€” Encrypted βœ…

v2.local.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxFULL_CIPHERTEXT
Enter fullscreen mode Exit fullscreen mode

Encrypted with XChaCha20-Poly1305. Opaque without the key.

TECTO β€” Encrypted βœ…

tecto.v1.my-key-2026.NONCE_BASE64URL.CIPHERTEXT_BASE64URL
Enter fullscreen mode Exit fullscreen mode

Encrypted with XChaCha20-Poly1305. The kid is visible (it's just a label), but the payload is mathematically unreadable without the key.


πŸ”„ Key Rotation Comparison

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

πŸ›‘οΈ Security Properties at a Glance

JWT HS256

  • βœ… Tamper-evident (HMAC)
  • ❌ Payload readable by anyone
  • ❌ Algorithm confusion attack surface
  • ❌ No entropy enforcement on secret

PASETO v2.local

  • βœ… Payload encrypted (XChaCha20-Poly1305)
  • βœ… No algorithm agility (version pins algo)
  • βœ… Authenticated encryption
  • ❌ No native key rotation
  • ❌ No entropy enforcement

TECTO

  • βœ… Payload always encrypted (XChaCha20-Poly1305)
  • βœ… Native key rotation with kid
  • βœ… Entropy validation on all keys
  • βœ… Generic errors (no oracle attacks)
  • βœ… Timing-safe comparisons
  • βœ… Memory zeroing on key removal
  • βœ… Payload size limits (DoS prevention)
  • βœ… Type-checked registered claims (prevents type confusion)

πŸ€” Which Should You Choose?

Use JWT if:

  • You need compatibility with OAuth / OIDC / existing infrastructure
  • Your payload contains no sensitive data (just user IDs, roles)
  • You're integrating with third-party services

Use PASETO v2.local if:

  • You want a well-audited, standardized encrypted token
  • You need interoperability across multiple languages/platforms
  • You don't need native key rotation

Use TECTO if:

  • You want encrypted-by-default with zero configuration mistakes possible
  • You need native key rotation without extra infrastructure
  • You're building a greenfield TypeScript/Bun project
  • You want defense-in-depth: entropy validation, generic errors, timing safety, memory zeroing

πŸš€ Getting Started with TECTO

bun add tecto
Enter fullscreen mode Exit fullscreen mode
import { generateSecureKey, MemoryKeyStore, TectoCoder } from "tecto";

const store = new MemoryKeyStore();
store.addKey("v1", generateSecureKey());

const coder = new TectoCoder(store);

// Encrypt
const token = coder.encrypt({ userId: 42 }, { expiresIn: "1h" });

// Decrypt
const { userId } = coder.decrypt<{ userId: number }>(token);
Enter fullscreen mode Exit fullscreen mode

For persistent key storage, TECTO ships with adapters for SQLite, PostgreSQL, and MariaDB β€” all following the same KeyStoreAdapter interface.


Final Thoughts

JWT will remain the standard for federated auth and OAuth flows for a long time β€” and that's fine. But for internal service-to-service tokens, session tokens, or any scenario where payload privacy matters, you should reach for an encrypted token format.

PASETO v2.local is a solid, standardized choice. TECTO goes a step further with batteries-included key rotation, entropy enforcement, and a security-first error model.

The best token protocol is the one you can't misconfigure. TECTO makes a strong case for that.

Top comments (0)