How to Add Passkeys to FastAPI in Python (2026 Guide)
A step-by-step guide to adding passkey (WebAuthn) login to a FastAPI app in Python — passwordless, phishing-resistant, and self-hosted, using the open-source fastapi-passkeys library.
Passwords are the weakest part of most apps I build. So when I needed passwordless, phishing-resistant login for a FastAPI service, I reached for passkeys — and ended up writing a small open-source library, fastapi-passkeys, so the security-sensitive parts are handled correctly by default.
This is the guide I wish I'd had: how to add passkeys to a FastAPI app in Python — self-hosted, with no third-party auth vendor.
TL;DR —
pip install fastapi-passkeys, mount one router, implement two small hooks (who is the user and what to do once they're verified), and you get working Face ID / Touch ID / security-key login. It's open source (MIT), fully typed, and it never touches your choice of session or database.
What is a passkey?
A passkey is a login credential built on the WebAuthn standard. Instead of a password, the browser creates a public/private key pair. The private key stays on the user's device (protected by Face ID, Touch ID, Windows Hello, or a hardware key like a YubiKey); your server only ever stores the public key.
That single design choice removes most of what goes wrong with passwords:
- Nothing to phish. The signature is bound to your domain, so a lookalike site can't reuse it.
- Nothing to leak. A stolen database of public keys is useless to an attacker.
- Nothing to remember. The user just approves with their face or fingerprint.
Why I built a library for it
WebAuthn is the strongest mainstream authentication we have, and also one of the easiest to implement subtly wrong. Skip an origin check, reuse a challenge, or ignore the signature counter, and you've built a lock that looks closed and isn't.
fastapi-passkeys owns exactly that hard part and stays out of everything else:
- Secure by default — single-use, TTL-bound challenges; strict origin / relying-party checks; and a monotonic
sign_countthat detects cloned authenticators. - Auth-agnostic — it verifies the passkey and hands you the user. You mint whatever session or JWT you already use.
- Storage-abstracted — credentials live behind an async repository protocol, with in-memory, SQLAlchemy 2.0, and Redis adapters included.
Unlike a hosted service, there's no vendor, no per-user pricing, and no data leaving your server. It's Alpha (0.1.x) and typed with mypy --strict.
Prerequisites
- Python 3.10+
- A FastAPI app
- HTTPS in production (WebAuthn requires a secure origin;
localhostis exempt for development)
Step 1 — Install
pip install fastapi-passkeys
# optional storage extras:
pip install "fastapi-passkeys[sqlalchemy]" # SQLAlchemy 2.0 async credential repo
pip install "fastapi-passkeys[redis]" # Redis challenge store (atomic single-use)
Step 2 — Mount the router
The whole integration is one Passkeys object and two hooks:
from fastapi import FastAPI, Request
from fastapi_passkeys import (
AuthenticationResult,
Passkeys,
PasskeyConfig,
PasskeyUser,
)
from fastapi_passkeys.contrib import InMemoryCredentialRepository
async def get_user(request: Request) -> PasskeyUser:
# However your app identifies the in-progress user: a signup token,
# an existing session, an email from the request body, etc.
return PasskeyUser(id="user-123", name="ada@example.com", display_name="Ada Lovelace")
async def on_authenticated(request: Request, result: AuthenticationResult) -> dict:
# The passkey is verified — now mint *your* session or token.
return {"access_token": issue_token(result.user_id)}
passkeys = Passkeys(
config=PasskeyConfig(
rp_id="example.com",
rp_name="Example",
expected_origins=["https://example.com"],
),
credential_repository=InMemoryCredentialRepository(),
get_user=get_user,
on_authenticated=on_authenticated,
)
app = FastAPI()
app.include_router(passkeys.router, prefix="/auth/passkeys", tags=["passkeys"])
passkeys.install_exception_handlers(app)
That's the entire server side. Two things worth understanding:
get_useranswers who is registering or logging in? You stay in control of identity — the library never invents a user model.on_authenticatedruns after the passkey is cryptographically verified. This is where you issue your own JWT or session cookie. The library deliberately does not own your session.
Step 3 — The begin → finish ceremony
Each flow (registration and login) is a begin → finish pair:
- Your frontend calls the begin endpoint. It returns the options you pass straight to
navigator.credentials.create()(registration) ornavigator.credentials.get()(login), plus an opaquestatehandle. - The browser prompts for Face ID / Touch ID / the security key.
- Your frontend sends the authenticator's response — with that same
state— back to the finish endpoint, which verifies it.
No server session is needed between the two calls; the state carries everything. On the browser side it looks like this:
// Registration (login is the same shape with navigator.credentials.get)
const begin = await fetch("/auth/passkeys/register/begin", { method: "POST" });
const { options, state } = await begin.json();
const credential = await navigator.credentials.create({ publicKey: options });
await fetch("/auth/passkeys/register/finish", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ state, credential }),
});
The exact paths are listed in your app's OpenAPI docs at /docs, and there's a full, runnable browser demo in examples/app.py in the repo.
Step 4 — Move from in-memory to a real database
InMemoryCredentialRepository is perfect for a first run, but it forgets everything on restart. Swap in the SQLAlchemy or Redis adapter when you're ready — the rest of your code doesn't change, because storage sits behind one async protocol:
from fastapi_passkeys.contrib import SQLAlchemyCredentialRepository
passkeys = Passkeys(
config=config,
credential_repository=SQLAlchemyCredentialRepository(sessionmaker),
get_user=get_user,
on_authenticated=on_authenticated,
)
The Redis adapter also gives you an atomic, single-use challenge store, which matters once you run more than one server process.
The hard parts it handles for you
This is the reason I'd reach for a library instead of hand-rolling WebAuthn:
- Single-use, TTL-bound challenges — a challenge can't be replayed, and it expires.
- Strict origin and relying-party validation — a signature minted for a phishing domain is rejected.
- Cloned-authenticator detection — the monotonic signature counter flags a credential that appears to have been copied.
- A user-verification policy — you decide whether a biometric/PIN is required.
Get any one of those wrong by hand and the lock quietly stops working. That's the whole argument for keeping the dangerous code in one small, tested place.
Beyond login: sessions and devices
Passkeys answer "who is this?" once. Everything after is sessions and devices — the part attackers actually live in. If you want "sign out everywhere" and "this wasn't me" to be single, auditable actions, I built a companion library, fastapi-trusted-devices, that binds each session to a known device and lets you revoke any of them. It pairs naturally with passkeys.
(And if you're building for Uzbekistan and need to take payments, unipay-uz gives you Payme, Click, Uzum, Paynet, and Octo behind one API — but that's a different post.)
FAQ
Do passkeys replace passwords entirely? They can. Many teams start by offering passkeys alongside passwords, then make passwords optional once adoption is healthy.
Is this secure without a third-party service?
Yes. WebAuthn is a browser and OS standard — the cryptography runs on the device and your server. fastapi-passkeys performs the verification locally; nothing leaves your infrastructure.
Which databases are supported? In-memory and SQLAlchemy 2.0 (async) ship in the box, plus a Redis challenge store. Anything else is a small adapter behind one protocol, and there's a contract test-suite to validate your own.
Does it lock me into a specific auth or ORM? No. It verifies the passkey and returns the user; your session, JWT, and database stay yours.
Sources & further reading
- PyPI: pypi.org/project/fastapi-passkeys
- GitHub (source + runnable demo): github.com/javlondevv/fastapi-passkeys
- Docs: javlondevv.github.io/fastapi-passkeys
- WebAuthn spec (W3C): w3.org/TR/webauthn-2
- MDN — Web Authentication API: developer.mozilla.org
- FastAPI docs: fastapi.tiangolo.com
If this saves you a day of WebAuthn debugging, please ⭐ star the repo — it's the single biggest thing that helps other developers find it. Questions or bugs are welcome on the issue tracker.


