Deed KeyAuth
Passwordless authentication built on device key pairs (Ed25519), with no passwords to
store or leak. The user's public key is their identity. The flow is a two-step handshake: the
slice mints a one-time challenge nonce, the client signs it with its private key, and
verify checks the signature and returns your slice's own session JWT.
// @atomic http=post:auth/challenge auth=none
nonce, _ := drift.Deed.KeyAuth.Challenge(pubkey) // single use, 120s
// @atomic http=post:auth/verify auth=none
token, _ := drift.Deed.KeyAuth.Verify(pubkey, sig, "my-app") // → this slice's JWT
The domain argument namespaces the signature per app, so a signature made for one app
can't be replayed against another. The private key never leaves the device; the slice only ever sees
public keys and signatures, and stores nothing about the user. The token's sub is the
identity and it expires 30 days out. A device that Link has revoked is
refused here even though its signature is genuinely valid.
Signing the challenge in the browser
The slice verifies a signature over one exact byte string: compact JSON, keys in sorted order, no whitespace. Build it literally rather than trusting a serializer's key ordering.
const hex = b => [...new Uint8Array(b)].map(x => x.toString(16).padStart(2, "0")).join("");
const domain = "my-app"; // the same string your Verify call passes
// 1. one keypair per device. The private key is non-extractable and never leaves.
const pair = await crypto.subtle.generateKey({ name: "Ed25519" }, false, ["sign", "verify"]);
const pubkey = hex(await crypto.subtle.exportKey("raw", pair.publicKey)); // 64 hex chars
// 2. ask your own function for a nonce
const { nonce } = await (await fetch("/api/auth/challenge", {
method: "POST", body: JSON.stringify({ pubkey }),
})).json();
// 3. sign the canonical message: this exact string, nothing else
const msg = `{"domain":${JSON.stringify(domain)},"nonce":${JSON.stringify(nonce)},"pubkey":${JSON.stringify(pubkey)}}`;
const bytes = new TextEncoder().encode(msg);
const sig = hex(await crypto.subtle.sign({ name: "Ed25519" }, pair.privateKey, bytes));
// 4. POST pubkey + sig to your verify function; it hands back the slice's JWTThe signed message must match byte for byte.
Verify) comes back as 401 bad signature with no
further detail. The client and Verify must use the same domain string; passing
an empty one to Verify makes the slice check against drift-keyauth-v1, so the
client has to sign that.
Authentication walks the same flow end to end, from login to a protected route.