Every auth system I've ever built has the same problem: the server has to hold a secret. A password hash, a session token, an API key. That secret is a liability. It gets breached. It gets phished. It gets rotated on a schedule nobody follows.
I wanted to see if I could build auth where the server holds nothing at all.
The result is arken-auth, a middleware for Express and FastAPI that uses cryptographic key ownership instead of passwords.
How it works
The protocol is called a401 — a401 because it's built on HTTP 401 challenge-response, same as Basic auth, but with signatures instead of passwords.
Client Server
│ │
├── GET /protected ────────────────▶│
│ │ (no auth header)
│◀── 401 { challenge: { nonce } } ──┤
│ │
│ signature = sign(nonce, privateKey)
│ │
├── GET /protected ────────────────▶│
│ Authorization: Signature │
│ did="did:key:z...", │
│ nonce="abc123", │
│ signature="0x..." │
│ │ recover signer from sig
│ │ compare to DID pubkey
│◀── 200 { did: "did:key:z..." } ───┤
- Client requests a protected resource with no auth header.
- Server generates a random nonce, stores it with a 60s TTL, and returns
401. - Client signs the nonce with their private key (it never leaves the device).
- Client sends back their DID, the nonce, and the signature.
- Server recovers the signer address from the signature using ECDSA recovery.
- Server checks the recovered address matches the DID's public key.
- Done.
No password transmitted. No session created. No database touched.
The identity format
Instead of usernames, users are identified by a did:key, a self-describing identifier that encodes a public key:
did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK
The z prefix means base58btc. Decode it, strip the multicodec prefix (0xe7 0x01 for secp256k1), and you have a 33-byte compressed public key.
The interesting property: this identity is portable. It's not bound to a server, a domain, or an account. You can use the same DID to authenticate to any server running arken-auth without registering anywhere.
Using it
npm install arken-auth
const express = require('express');
const { arkenAuth } = require('arken-auth');
const app = express();
app.get('/protected', arkenAuth(), (req, res) => {
res.json({ did: req.user.did });
});
app.listen(3000);
That's the entire server. The middleware handles the full challenge-response cycle: 401 on the first hit, verification on the second.
On the client side, any secp256k1 wallet works. With ethers.js:
const wallet = ethers.Wallet.createRandom();
// Derive the DID from the wallet's public key
const did = walletToDid(wallet); // did:key:z...
// Step 1: get the challenge
const r1 = await fetch('/protected');
const { challenge: { nonce } } = await r1.json();
// Step 2: sign the nonce
const signature = await wallet.signMessage(
ethers.utils.toUtf8Bytes(nonce)
);
// Step 3: authenticate
const r2 = await fetch('/protected', {
headers: {
Authorization: `Signature did="${did}",nonce="${nonce}",signature="${signature}"`
}
});
const { did: authenticatedDid } = await r2.json();
The crypto core
The actual cryptography is written in Rust, about 250 lines using ethers-rs and bs58. Node.js calls it over FFI via koffi. Python calls it via ctypes. The Rust compiles to a native .so/.dylib/.dll at install time.
The key operation is ECDSA signature recovery: given a message and a signature, recover the public key that produced it. This is the same primitive Ethereum uses to verify transactions. You don't need to know the public key ahead of time; you derive it from the signature itself.
// Recover signer address from signature
let recovered_address = sig.recover(nonce.as_bytes())?;
// Check it matches the DID
if to_checksum(&recovered_address, None) != to_checksum(&expected_address, None) {
return Err(ArkenAuthError::SignatureMismatch);
}
What it doesn't do (yet)
I want to be honest about the limitations:
-
Requires Rust to install. The postinstall script runs
cargo build --release. Prebuilt binaries for Linux/macOS/Windows are the next thing on the list. - In-memory nonce store. Nonces don't survive restarts and won't work across multiple server instances without a shared store. A Redis adapter is straightforward but not built yet.
- No account recovery. Lose the private key, lose access. This is fine for developer tools and APIs. It's a dealbreaker for consumer apps where normal people forget things.
- Not a WebAuthn replacement for consumers. WebAuthn has better phishing resistance for end users. This is aimed at developer tools, APIs, CLIs, and server-to-server auth where portable identity matters more than browser UX.
Why I built this
I got frustrated with auth as an always-on liability. Every app I build means another database with hashed passwords that I'm responsible for. One breach, one leaked backup, one misconfigured S3 bucket away from an incident.
The model I wanted: the server verifies identity without storing anything about it. Cryptographic keys make this possible. DIDs make the identity portable. The only question was whether it could be simple enough to actually use.
npm install arken-auth and two lines of middleware is my answer to that.
Source code, Python version, and architecture notes: https://github.com/zacharybenson/arken-protocol
Would love feedback, especially from anyone who's tried building something similar or hit the walls I'm about to hit.
Top comments (2)
This is a really interesting project! I am an AI-based software engineer currently learning FastAPI, LangChain, and AI agent development. That is why your research on authentication middleware is particularly interesting to me. I would like to share ideas and learn more about how arken-auth can support AI-based applications. I am always welcome to make new friends in the tech field, so please feel free to contact me anytime!
Thanks, Manuel! Glad it caught your eye.
arken-auth grew out of x402 (x402.org), which brings back the old HTTP 402 “Payment Required” status code.
A client asks for something, the server says “this costs X,” the client sends proof of payment and tries again, and that’s it. No accounts, no API keys. It was built so agents could pay each other without a human in the loop.
I took that same flow and pointed it at access instead of payment. The client proves it’s allowed to use a resource the same way it would prove it paid, so there’s no long-lived API key sitting around waiting to leak. Since it’s all plain HTTP, it works just as well for agent-to-agent setups, which sounds like it fits what you’re building with FastAPI and LangChain. I’d love to hear how you’d want to use it.