← Back to writing

Fast wasn't the point

Everyone assumes we picked Rust for FerrisKey because it's fast. It is, but that's a side effect, not the argument. The real reason is what the compiler refuses to let us ship, why our tests look nothing like a typical Java or Node test suite, and why that same determinism is what makes simulation testing and formal proofs a realistic next step instead of a pipe dream.

Published July 26, 2026

Every time we mention that FerrisKey is written in Rust, the first reaction is the same: “oh, so it’s fast.” It is. Our comparison numbers are public: a few megabytes of memory at idle, a cold start under a second, next to a JVM-based IAM that needs hundreds of megabytes and half a minute to be ready to answer a single request.

But if speed were the whole argument, we’d have a marketing page, not this post. Speed is what you notice from the outside. It’s not why we bet a security-critical piece of infrastructure on this language.

The bug class an IAM cannot afford

An IAM sits where attackers hit first, and where latency matters most. Every authenticated request in your infrastructure passes through it. It parses tokens it didn’t generate, headers it doesn’t control, payloads sent by clients it has no reason to trust.

Historically, that exact kind of code (parsers, crypto, session handling, written in C or C++) is where our industry’s worst vulnerabilities have come from. Heartbleed wasn’t a design flaw in TLS. It was a buffer over-read in OpenSSL’s memory handling: a bug class, not a business logic mistake.

Rust’s ownership model removes that bug class at compile time, not through discipline, code review, or a fuzzer that happened to find it first. A FerrisKey binary that compiles cannot have a data race or a use-after-free. That’s not a testing result. It’s a mathematical property of the code that made it past cargo build.

And it’s not just the application code. The TLS stack is rustls, not OpenSSL: memory-safe, with no C dependency, in the exact layer that talks to the network first. Password hashing runs on argon2. Passkeys and WebAuthn run on webauthn-rs. The code that touches what an attacker sends you is written in the same memory-safe language as the rest of the system, not bolted on across an FFI boundary into C.

The compiler as a wall, not a suggestion

This is the part that doesn’t show up in a benchmark: Rust lets us push rules from “documented in a wiki page, hopefully respected” to “the code does not compile otherwise.”

Take the login flow. A single authentication attempt in FerrisKey can only end one of four ways:

#[derive(Debug, Clone, PartialEq)]
pub enum AuthenticationStepStatus {
    Success,
    RequiresActions,
    RequiresOtpChallenge,
    Failed,
}

Nothing exotic. But the handler that turns this into an HTTP response has to match on it, and Rust refuses to compile a match that forgets a variant. Add a fifth outcome tomorrow (say, a passkey challenge step) and every response mapper that needs to know about it either handles it explicitly or the build breaks. Nobody has to remember to update it. Nobody can forget, because forgetting isn’t a code path anymore, it’s a compiler error.

That’s the shift: a rule stops being something a senior engineer has to remember to check in review, and becomes something the type system enforces on every commit, from every contributor, forever.

A testing strategy that looks nothing like the usual one

If invalid states can’t be represented, and forgotten cases can’t compile, your tests stop needing to defend against them. That changes what you actually test, and how.

FerrisKey follows a hexagonal architecture: the domain defines ports (traits) for everything that talks to the outside world: the database, the mailer, the webhook dispatcher. Several of those crates ship a mock feature:

ferriskey-security  = { path = "../libs/ferriskey-security",  features = ["mock"] }
ferriskey-seawatch   = { path = "../libs/ferriskey-seawatch",  features = ["mock"] }
ferriskey-webhook    = { path = "../libs/ferriskey-webhook",   features = ["mock"] }

That mock isn’t a runtime stand-in generated by reflection, the way Mockito or jest.mock() work. It’s a real, separate implementation of the exact same trait, checked by the compiler like any other. If the port’s contract changes, the mock either gets updated to match or the whole workspace stops building. It cannot silently drift out of sync with what production actually calls.

The result is a test suite that spends its effort on business rules (is this transition allowed, does this policy apply, is this token actually expired) instead of re-proving, in every single test file, that the mock doesn’t crash or return the wrong shape. The compiler already proved that part.

Capitalizing without trading away security

Every module we ship (Trident for MFA and WebAuthn, SeaWatch for audit trails, our webhook system) is its own crate, talking to the domain core only through those same ports. That’s not a folder convention that a rushed PR can quietly break. A module cannot reach around the security core to touch something it has no port for; there’s no path in the compiled binary for it to do so.

That’s what lets us build faster without spending that speed against our own security posture. Growing the feature surface and keeping the trust boundary intact aren’t in tension here. The second is what the compiler is already enforcing while we do the first.

Fewer surprises, in theory

An IAM isn’t a peripheral service. It’s the piece every other system in your information system quietly depends on staying correct. When it’s wrong, it’s not a degraded feature. It’s every downstream login, every downstream authorization check, at once.

For the SREs who get paged when it isn’t: no garbage collector means no GC pause showing up as a latency spike under load. Exhaustive matches on realms, roles, and session states mean an “impossible” case that Java or Python would happily let you construct at runtime simply doesn’t exist as a value here. None of this makes incidents impossible. Bad deploys, bad configs, and bad assumptions about the world are still entirely possible. But a whole category of “how did this even happen” surprises is closed off before the binary ever ships. Fewer unknown unknowns, in theory. And in a system this central, that theory is worth having on your side.

What determinism buys us next

Memory safety gets the headlines, but it’s not the only property this design gives us for free. Ownership and the absence of a garbage collector also make the runtime deterministic: no GC pause, no JIT warm-up curve, no allocator quietly deciding to compact the heap under load. Feed the same input to the same binary twice and it takes the same path both times, with the same timing characteristics. That’s not just a safety property, it’s a performance one too: the same determinism that rules out data races is what keeps tail latency boring under load.

That determinism is also a foundation, not just an outcome, and it points at two things we want FerrisKey to grow into.

The first is simulation-driven development. Systems like FoundationDB and TigerBeetle run their entire test suite against a simulated clock and network, replaying byte-for-byte identical event sequences while injecting failures (dropped connections, delayed disk writes, clock skew) and reproducing a rare bug from a single seed instead of chasing a flaky CI run for a week. That kind of harness only works if the program doesn’t have hidden non-determinism to fight against. We don’t have this built for FerrisKey today, but it’s a direction Rust actually makes reachable, in a way a language where GC timing is one more source of noise to simulate around does not.

The second is formal proofs on the invariants that matter most. Not proving the whole system correct, that’s not a realistic bar for a project this size, but specific, narrow claims worth actually proving instead of only testing: a token that’s been revoked can never be accepted again, a realm boundary can never be crossed by a code path that forgot to check it. Rust’s ecosystem has verification tools built for exactly this (Kani, Creusot, and contract-based crates), and they’re tractable here for the same reason the rest of this post exists: the type system already rules out the aliasing and hidden mutation that make formal reasoning impractical in most other languages. This is on our roadmap, not in production, but it’s a much shorter walk from Rust than from a runtime where “what could this pointer alias” doesn’t have a clean answer.

What we’re not going to pretend

None of this is free, and we’re not pretending it is. Rust’s learning curve is real, especially for teams used to a GC doing memory management for them. The talent pool is smaller than Java’s or Go’s, though it’s growing fast, and it skews toward engineers who actively sought out these guarantees. Compile times are longer than a dynamically typed language’s “just run it.” Parts of the async ecosystem are still younger than their JVM or Node equivalents.

We think that trade is worth making for exactly one category of software: the piece that sits in the critical path of everything else, that attackers hit first, and that has to be right more than it has to be quick to write on day one. An IAM is that category.

The part everyone actually asked about

So, back to where this started: yes, FerrisKey is fast. A few megabytes at idle instead of hundreds. A cold start under a second instead of thirty. Now you know why that’s not the interesting part. It’s the visible side effect of everything above it: no GC to warm up, no heap to grow, a binary that either fully proves what it claims or doesn’t compile at all.

Speed was never the argument. It’s just what you can measure from the outside.


FerrisKey is open source, Apache 2.0, and the code discussed here is public. If you want to see the real authentication flow, the actual ports, or the mock features in context, the repo is on GitHub. Clone it, read it, argue with our choices in an issue. That’s the point of doing this in the open.