In 2019, when I set out to build rusty_paseto, a Rust implementation of the PASETO token specification, I wasn't just thinking about correctness. I was thinking about the developer who would use it at 2am during an incident, the one copy-pasting from Stack Overflow, the one who just wants tokens to work without becoming a cryptography expert.
The question that drove every design decision: How do I make the right thing easy and the wrong thing hard?
This is the "pit of success" - a term coined by Rico Mariani to describe APIs where users naturally fall into correct usage. Most APIs are pits of despair: the easy path leads to bugs, and correctness requires climbing toward it. A pit of success inverts this. The path of least resistance is also the path of correctness. Rust's type system lets us encode these constraints directly, catching violations before the code runs.
The Hidden Cost of Friction
First, some context on tokens lest you think I mean the crypto-bro variety: when you log into a website, you typically receive a token, a cryptographically signed piece of data proving your identity. Your browser sends this token with each request, letting the server verify who you are without re-authenticating. These tokens are everywhere, securing APIs, single sign-on systems, and mobile apps.
PASETO (Platform-Agnostic Security Tokens) is a token specification designed to avoid the security pitfalls of its predecessor, JWT (JSON Web Tokens). Where JWT allowed developers to choose from many cryptographic algorithms, including some that have proven problematic, PASETO takes an opinionated approach: each version specifies exactly which algorithms to use, eliminating a class of configuration mistakes.
But even correct specifications can be misused. In 2015, security researcher Tim McLean demonstrated "algorithm confusion" attacks against JWT libraries. An attacker could forge valid tokens by exploiting how libraries handled algorithm selection. The vulnerability wasn't in the cryptography. It was in API design that trusted user-controlled input (the algorithm header) to determine verification behavior.
Most security libraries fail in one of two ways. They're either so low-level that developers make mistakes, or so high-level they can't handle edge cases. The developer either footguns themselves with raw cryptographic primitives, or fights the abstraction when their use case doesn't fit.
I wanted something different: progressive disclosure of complexity. Start simple, go deeper only when you need to.
Making Invalid States Unrepresentable
The core insight is that a programming language's type system can encode security constraints that other languages express only through documentation. If you're unfamiliar with Rust, here's the key idea: the Rust compiler checks not just that your code runs, but that it uses data in ways the type system allows. This means certain categories of bugs, including some security vulnerabilities, become impossible to write.
Consider PASETO's version and purpose system: version 4 tokens use modern cryptographic primitives (XChaCha20-Poly1305 for encryption, Ed25519 for signatures), while the "local" purpose indicates symmetric encryption (same key encrypts and decrypts) versus "public" for asymmetric signing (private key signs, public key verifies).
Rather than accepting string parameters like version: "v4" and purpose: "local", rusty_paseto encodes these as distinct types, separate categories that the compiler treats as fundamentally incompatible:
// These are different types, not string values
PasetoBuilder::<V4, Local>::default()
.build(&key_v4_local)? // ✅ Compiles
PasetoBuilder::<V4, Local>::default()
.build(&key_v3_public)? // ❌ Type error: caught at compile time
You physically cannot build a V4 Local token with a V3 Public key. The compiler rejects it. This isn't a runtime check that might fail in production. It's a compile-time guarantee that eliminates an entire class of bugs. The mistake is caught in your editor, seconds after you write it, not in production after you've deployed it.
This extends to capability-based APIs. Only V3 and V4 tokens support implicit assertions (additional authenticated data bound to the token). Rather than document this and hope developers read it, I used marker traits that make the method simply not exist for V1 or V2 tokens. The wrong thing is impossible, not just discouraged.
Layered Abstraction: Three Layers, One Crate
rusty_paseto is structured as three distinct layers, each building on the last. This is a pattern sometimes called "progressive disclosure" in interface design:
Core is just cryptographic primitives. No JSON serialization, no claim handling, no opinions. For the rare user who needs maximum control or has custom serialization requirements.
Generic adds the claims system and JSON serialization, but no defaults. Claims are the key-value pairs inside a token: who issued it, when it expires, who it's for. At this layer, you control everything: expiration times, validation rules, what gets checked.
Batteries-included (the prelude) is where most developers should live. Sensible defaults: one-hour expiration, automatic time validation. Create a secure token in one line:
let token = PasetoBuilder::<V4, Local>::default().build(&key)?;
That's it. You get a token that expires in an hour, has proper timestamps, and just works.
The naming is intentional. "Batteries included" signals what you're getting. If you import from prelude, you're opting into opinions. If you drop down to generic or core, you're signaling "I know what I'm doing."
Making Wrong Code Look Wrong
One of my favorite design patterns: making dangerous operations syntactically obvious.
Want to create a token that never expires? In many libraries, you'd just omit the expiration field. But non-expiring tokens are a security risk. If one is stolen, it's valid forever. In rusty_paseto, you can't just omit the expiration. You have to write this:
PasetoBuilder::<V4, Local>::default()
.set_no_expiration_danger_acknowledged()
.build(&key)?
You have to type danger_acknowledged. It's not hidden in a boolean flag or buried in configuration. The code literally says "I understand this is dangerous."
Similarly, the V1 public-key feature is named v1_public_insecure in the project's configuration file. If you enable it, "insecure" appears in your dependency manifest. This follows a security advisory (V1 public tokens have known weaknesses) and the naming makes that visible at the project configuration level, not just in documentation nobody reads.
Constructor Semantics as Communication
Here's a subtle design choice I'm particularly fond of. The token parser has two constructors:
| Constructor | Behavior |
|---|---|
PasetoParser::default() | Auto-validates expiration and timing claims |
PasetoParser::new() | No automatic validation |
This inverts typical patterns, where new() is usually the primary constructor. Here, default() is the safe path. You get time validation automatically. If you want to skip that validation (perhaps for testing, or for tokens with unusual lifetimes), you must explicitly call new().
Developers who reach for the obvious choice, the default, get the secure behavior. Opting out requires a conscious decision.
Fluent APIs with Honest Return Types
The builder pattern lets you construct objects step by step, chaining method calls together. But in rusty_paseto, the return types communicate something important about what can go wrong:
builder
.audience("api") // Returns Self (can't fail)
.subject("user-123") // Returns Self (can't fail)
.claim("user_id", 42)? // Returns Result (can fail)
.build(&key)?
Simple setters like audience() and subject() return the builder directly. They can't fail, so there's no error handling needed. But claim() validates that you're not accidentally overwriting a reserved claim name (like exp for expiration), so it returns a result that must be checked.
The ? operator in the code tells you exactly which operations might fail. You can read the code and understand the failure modes without checking documentation.
What This Approach Teaches
Building rusty_paseto reinforced several principles that extend beyond cryptography and beyond Rust:
Constraints liberate. The more a system constrains what's possible, the fewer decisions developers must make and the fewer mistakes they can make. Explicit limits that seem verbose at first become guardrails that prevent entire categories of bugs.
Interfaces have two users. The immediate user wants to accomplish their task. The future maintainer needs to understand what the code does and why. Well-designed interfaces serve both: they guide immediate usage and document intent for later readers.
Layering preserves options. By separating concerns into distinct layers, you avoid forcing choices on users. Those who need simplicity get it; those who need control have it. Neither compromises the other.
Make the right thing easy. Good design isn't about preventing all possible misuse. It's about making correct usage the path of least resistance. When the safe option is also the convenient option, developers will naturally choose it.
These patterns transfer directly to any domain where correctness matters. The specific details of PASETO are less important than the design methodology: identify the constraints that matter, make them explicit in your interface, and let the system enforce what documentation can only suggest.
rusty_paseto is available on crates.io and GitHub. If you're interested in PASETO tokens or just want to see these patterns in action, take a look.
Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.