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
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
β οΈ 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"}
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
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
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);
β What PASETO gets right
- No algorithm confusion β the version (
v2) pins the algorithm -
v2.localencrypts the payload (XChaCha20-Poly1305) - Clean, modern API with async support
β οΈ What PASETO still lacks
- No native key rotation story β
kidis 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
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
The token format is self-describing
tecto.v1.<kid>.<nonce>.<ciphertext>
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");
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
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)
}
}
π¬ 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
Anyone can atob() the middle segment. No key needed.
PASETO v2.local β Encrypted β
v2.local.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxFULL_CIPHERTEXT
Encrypted with XChaCha20-Poly1305. Opaque without the key.
TECTO β Encrypted β
tecto.v1.my-key-2026.NONCE_BASE64URL.CIPHERTEXT_BASE64URL
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
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);
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)