drift Docs
Start
What is Drift?
The tour, if you are new here.
Why Drift?
The case for a smaller cloud.
Getting started
Nothing to deployed, in one command.
Architecture
How a slice is put together.
What it costs
The free grant, five unit prices, two rules.
Build
Canvas
Static sites, same origin as your API.
Tools
Operate
Auth
Accounts, tokens and scopes.
Security
Boundaries, sandboxing and hardening.

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.

Go
// @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.

Node.js
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 JWT

The signed message must match byte for byte.

One that differs by a single byte (a space after a colon, a different key order, a domain the function did not pass to 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.