A complete, minimal example: a static HTML/JS front end that generates a keypair and signs a challenge, and a FastAPI back end that verifies it — with an email magic-link for new devices and a manual export/import escape hatch for people who would rather not give an email at all.
login.html. All four are also linked individually so you can read them in full.
↓ server.py ↓ keyauth.js ↓ login.html
pip install "fastapi" "uvicorn[standard]" "cryptography"
uvicorn server:app --reload # serves the API on :8000
# then serve the static files from the same folder, e.g.
python -m http.server 8000 # or open login.html directly
Register with an email, click log in, and the page signs a fresh challenge and shows your session. The private key is generated in the browser and never sent anywhere.
The Web Crypto API does the heavy lifting. We create an ECDSA P-256 pair, keep the private key locally, and export only the public key (in SPKI form) to send to the server.
export async function createKeypair() {
const kp = await crypto.subtle.generateKey(
{ name: "ECDSA", namedCurve: "P-256" },
true, // extractable: needed for manual export
["sign", "verify"]
);
const privJwk = await crypto.subtle.exportKey("jwk", kp.privateKey);
const spki = await crypto.subtle.exportKey("spki", kp.publicKey);
localStorage.setItem("kp_priv_jwk", JSON.stringify(privJwk));
localStorage.setItem("kp_pub_spki", b64u.enc(spki));
return b64u.enc(spki); // this is what the server stores
}
extractable: true is what makes the manual export feature possible — and also what makes the key readable by script. If you drop manual export, generate it false and keep the CryptoKey in IndexedDB so no script can ever read the private key out.
Login is three calls: ask for a challenge, sign it, send the signature. The private key stays in the browser the whole time.
export async function login() {
const pub = publicKey();
const { nonce } = await post("/api/challenge", { public_key: pub });
const signature = await sign(nonce);
return post("/api/verify", { public_key: pub, nonce, signature });
}
export async function sign(message) {
const priv = await loadPrivate();
const sig = await crypto.subtle.sign(
{ name: "ECDSA", hash: "SHA-256" }, priv,
new TextEncoder().encode(message));
return b64u.enc(sig); // raw r||s, base64url
}
On the server, verification looks up the stored public key and checks the signature against the exact nonce it issued. Note the nonce is single-use and short-lived — that is what defeats replay.
@app.post("/api/verify")
def verify_login(r: VerifyReq):
entry = CHALLENGES.pop(r.nonce, None) # single use
if not entry or entry[1] < time.time():
raise HTTPException(400, "challenge expired or unknown")
owner = next((e for e, keys in USERS.items()
if r.public_key in keys), None)
if owner is None:
raise HTTPException(401, "unknown key")
if not verify(r.public_key, r.nonce.encode(), r.signature):
raise HTTPException(401, "bad signature")
token = secrets.token_urlsafe(32)
SESSIONS[token] = (owner, time.time() + SESSION_TTL)
return {"session": token, "email": owner, "ttl": SESSION_TTL}
The one fiddly detail: Web Crypto emits a raw r||s signature, while Python's cryptography expects DER. The backend converts between them:
raw = b64u_decode(sig_b64u)
r = int.from_bytes(raw[:32], "big")
s = int.from_bytes(raw[32:], "big")
pub.verify(encode_dss_signature(r, s), message, ec.ECDSA(hashes.SHA256()))
This is the path most humans will use. The user gives an email; the server mails a short-lived, single-use ticket — never the key. The new browser clicks the link, mints its own brand-new keypair locally, and registers that new public key against the account.
# backend: issue a ticket (printed to console in the demo)
@app.post("/api/magic")
def magic(r: MagicReq):
if r.email in USERS: # don't leak which emails exist
token = secrets.token_urlsafe(32)
MAGIC[token] = (r.email, time.time() + MAGIC_TTL)
link = f"http://localhost:8000/login.html?enroll={token}"
print(f"[magic link for {r.email}] {link}") # email this in prod
return {"ok": True}
// front end: the new browser makes its OWN key, then enrolls it
export async function enroll(token) {
const pub = await createKeypair(); // fresh key, stays local
return post("/api/enroll", { token, public_key: pub });
}
Some people would rather not hand over an email at all. For them, offer a direct export/import: show the key bundle in the first browser, copy it, paste it into the second. The key still only ever moves between the user's own devices, under their control — it never passes through your server or a mail provider.
// export: bundle both keys into one portable string
export function exportKeys() {
return JSON.stringify({
priv: localStorage.getItem("kp_priv_jwk"),
pub: localStorage.getItem("kp_pub_spki"),
});
}
// import: drop that string into another browser
export function importKeys(blob) {
const { priv, pub } = JSON.parse(blob);
if (!priv || !pub) throw new Error("invalid key bundle");
localStorage.setItem("kp_priv_jwk", priv);
localStorage.setItem("kp_pub_spki", pub);
}
In login.html this is the “Show my keys” button and the paste box beneath it. It is deliberately blunt: the honest, no-server, no-email option, with the obvious caveat that the user is now responsible for handling that string carefully.