Created
June 20, 2026 06:32
-
-
Save joostd/c62472e82dc0513014a4e5c4e24212e4 to your computer and use it in GitHub Desktop.
WebAuthn demo in Python
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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> |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Run demo
Open http://localhost:8000 in a web browser.