Skip to content

Instantly share code, notes, and snippets.

@joostd
Created June 20, 2026 06:32
Show Gist options
  • Select an option

  • Save joostd/c62472e82dc0513014a4e5c4e24212e4 to your computer and use it in GitHub Desktop.

Select an option

Save joostd/c62472e82dc0513014a4e5c4e24212e4 to your computer and use it in GitHub Desktop.
WebAuthn demo in Python
import logging
import os
import secrets
from flask import Flask, request, jsonify, send_from_directory
from fido2.server import Fido2Server
from fido2.webauthn import (
PublicKeyCredentialRpEntity,
PublicKeyCredentialUserEntity,
ResidentKeyRequirement,
)
from fido2.utils import websafe_decode
logging.basicConfig(level=logging.DEBUG)
log = logging.getLogger(__name__)
app = Flask(__name__, static_folder="static")
@app.before_request
def log_request():
if request.method == "POST":
log.debug("POST %s %s", request.path, request.get_json(silent=True))
RP_ID = os.environ.get("RP_ID", "localhost")
server = Fido2Server(
PublicKeyCredentialRpEntity(id=RP_ID, name="WebAuthn Demo"),
)
credentials_db = {} # username -> [AttestedCredentialData]
states = {} # session -> state from begin calls
@app.route("/", defaults={"path": "index.html"})
@app.route("/<path:path>")
def serve_static(path):
return send_from_directory("static", path)
@app.route("/register/begin", methods=["POST"])
def register_begin():
username = request.json["username"]
user = PublicKeyCredentialUserEntity(
id=username.encode(), name=username, display_name=username
)
options, state = server.register_begin(
user,
credentials_db.get(username, []),
resident_key_requirement=ResidentKeyRequirement.PREFERRED,
)
session = secrets.token_hex(16)
states[session] = state
return jsonify({"session": session, **dict(options)})
@app.route("/register/complete", methods=["POST"])
def register_complete():
username = request.json["username"]
state = states.pop(request.json.get("session"), None)
if not state:
return jsonify({"error": "no active registration"}), 400
try:
auth_data = server.register_complete(state, request.json["credential"])
credentials_db.setdefault(username, []).append(auth_data.credential_data)
return jsonify({"status": "ok"})
except Exception as e:
return jsonify({"error": str(e)}), 400
@app.route("/login/begin", methods=["POST"])
def login_begin():
username = request.json.get("username")
creds = credentials_db.get(username, []) if username else None
options, state = server.authenticate_begin(creds)
session = secrets.token_hex(16)
states[session] = state
return jsonify({"session": session, **dict(options)})
@app.route("/login/complete", methods=["POST"])
def login_complete():
state = states.pop(request.json.get("session"), None)
user_handle = request.json["credential"]["response"].get("userHandle")
if not user_handle:
return jsonify({"error": "no userHandle in response"}), 400
username = websafe_decode(user_handle).decode()
if not state:
return jsonify({"error": "no active login"}), 400
try:
server.authenticate_complete(
state, credentials_db.get(username, []), request.json["credential"]
)
return jsonify({"status": "ok", "username": username})
except Exception as e:
return jsonify({"error": str(e)}), 400
if __name__ == "__main__":
app.run(port=8000, debug=True)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>WebAuthn Demo</title>
<style>
body { font-family: monospace; max-width: 480px; margin: 60px auto; padding: 0 20px; }
h1 { font-size: 1.2rem; }
section { margin: 2rem 0; }
input { width: 100%; box-sizing: border-box; padding: 6px; margin: 4px 0 8px; }
button { padding: 6px 16px; cursor: pointer; }
.status { margin-top: 8px; font-size: 0.9rem; }
.ok { color: green; }
.err { color: red; }
</style>
</head>
<body>
<h1>WebAuthn Demo</h1>
<section>
<h2>Register</h2>
<input id="reg-user" type="text" placeholder="username">
<button onclick="register()">Register</button>
<div id="reg-status" class="status"></div>
</section>
<section>
<h2>Login</h2>
<button onclick="login()">Login</button>
<div id="auth-status" class="status"></div>
</section>
<script>
function post(url, body) {
return fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
}).then(r => r.json());
}
function setStatus(id, msg, ok) {
const el = document.getElementById(id);
el.textContent = msg;
el.className = 'status ' + (ok ? 'ok' : 'err');
}
async function register() {
const username = document.getElementById('reg-user').value.trim();
if (!username) return setStatus('reg-status', 'Please enter a username', false);
try {
const opts = await post('/register/begin', {username});
if (opts.error) throw new Error(opts.error);
const cred = await navigator.credentials.create({
publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(opts.publicKey),
});
const result = await post('/register/complete', {username, session: opts.session, credential: cred.toJSON()});
if (result.error) throw new Error(result.error);
setStatus('reg-status', 'Registration successful', true);
} catch (e) {
setStatus('reg-status', e.message, false);
}
}
async function login() {
try {
const opts = await post('/login/begin', {});
if (opts.error) throw new Error(opts.error);
const cred = await navigator.credentials.get({
publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(opts.publicKey),
});
const result = await post('/login/complete', {session: opts.session, credential: cred.toJSON()});
if (result.error) throw new Error(result.error);
setStatus('auth-status', `Login successful as ${result.username}`, true);
} catch (e) {
setStatus('auth-status', e.message, false);
}
}
</script>
</body>
</html>
@joostd

joostd commented Jun 20, 2026

Copy link
Copy Markdown
Author

Run demo

mkdir static
cp index.html static/

python3 -m venv venv
. venv/bin/activate
pip3 install flask fido2
python3 demo.py

Open http://localhost:8000 in a web browser.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment