Passwordless Auth with Passkeys Tutorial
Imagine logging into your favorite app without ever typing a password again. No more “forgot password” emails, no more password reuse nightmares, and a smoother experience for users. This tutorial walks you through building a passwordless authentication flow with passkeys, using modern WebAuthn APIs on the frontend and a lightweight Flask backend in Python. By the end, you’ll have a working demo, a solid understanding of the underlying security model, and actionable tips to take your implementation to production.
What Are Passkeys?
Passkeys are a user‑centric replacement for passwords, built on the WebAuthn and FIDO2 standards. Instead of a secret string stored on a server, a cryptographic key pair lives on the user’s device—typically a secure enclave or a platform authenticator. The private key never leaves the device, while the public key is stored on the server and used only for verification.
Because the private key is hardware‑bound and protected by biometrics or a PIN, phishing attacks become practically impossible. Even if an attacker steals the public key, they cannot forge a valid authentication response without the corresponding private key.
How Passkeys Work Under the Hood
WebAuthn defines two primary operations: registration (also called “credential creation”) and authentication (or “assertion”). During registration, the browser asks the authenticator to generate a new key pair and returns a clientDataJSON and an attestationObject. The server extracts the public key from the attestation and stores it alongside the user’s identifier.
During authentication, the server sends a challenge to the client. The authenticator signs this challenge with the private key, producing an authenticatorData and a signature. The server verifies the signature using the stored public key, confirming the user’s identity.
Pro tip: Always generate a fresh, cryptographically random challenge for each authentication attempt. Reusing challenges defeats the purpose of the protocol and opens the door to replay attacks.
Setting Up a Passkey‑Enabled Backend (Python/Flask)
Project Structure
- app.py – main Flask application
- templates/ – HTML pages for registration & login
- static/ – JavaScript that talks to WebAuthn APIs
- utils.py – helper functions for encoding/decoding
Installing Dependencies
pip install flask cryptography
We’ll use cryptography for base64url handling and hashes required by WebAuthn.
Generating Credential Options (Registration)
# utils.py
import os, base64, json
from cryptography.hazmat.primitives import hashes
def b64url_encode(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b'=').decode('utf-8')
def generate_challenge() -> str:
return b64url_encode(os.urandom(32))
def registration_options(user_id: str, username: str) -> dict:
challenge = generate_challenge()
return {
"publicKey": {
"challenge": challenge,
"rp": {"name": "Codeyaan Demo"},
"user": {
"id": b64url_encode(user_id.encode()),
"name": username,
"displayName": username,
},
"pubKeyCredParams": [
{"type": "public-key", "alg": -7}, # ES256
{"type": "public-key", "alg": -257} # RS256
],
"authenticatorSelection": {
"authenticatorAttachment": "platform",
"userVerification": "required"
},
"timeout": 60000,
"attestation": "none"
}
}
In app.py, expose an endpoint that returns this JSON to the browser.
# app.py
from flask import Flask, request, jsonify, session
from utils import registration_options, generate_challenge
app = Flask(__name__)
app.secret_key = 'super-secret-key' # replace with env var in prod
# In‑memory store for demo purposes
users = {}
@app.route('/register/options', methods=['POST'])
def register_options():
data = request.json
username = data['username']
user_id = str(len(users) + 1) # simple incremental ID
session['challenge'] = generate_challenge()
options = registration_options(user_id, username)
# Store user temporarily until verification
users[username] = {'id': user_id, 'public_key': None}
return jsonify(options)
Verifying the Attestation (Registration Completion)
import base64, json
from cryptography.hazmat.primitives.asymmetric import ec, rsa
from cryptography.hazmat.primitives import serialization
def parse_attestation(attestation_response: dict) -> dict:
# Decode base64url fields
client_data = base64.urlsafe_b64decode(
attestation_response['clientDataJSON'] + '==')
att_obj = base64.urlsafe_b64decode(
attestation_response['attestationObject'] + '==')
# For brevity, we skip full CBOR parsing; use a library like cbor2 in real code
return {
"client_data": client_data,
"attestation": att_obj
}
@app.route('/register/verify', methods=['POST'])
def register_verify():
data = request.json
username = data['username']
attestation = parse_attestation(data['credential'])
# Extract public key from attestation (simplified)
# In production, validate attestation format, trust path, etc.
# Here we assume ES256 and extract the raw key from the authData
# This is just illustrative; use a proper WebAuthn library.
public_key_pem = attestation['attestation'] # placeholder
users[username]['public_key'] = public_key_pem
return jsonify({"status": "ok"})
After successful verification, the server now knows the user’s public key and can associate it with the account.
Integrating Passkeys on the Frontend (JavaScript)
Registration Flow
// static/register.js
async function startRegistration() {
const username = document.getElementById('username').value;
const response = await fetch('/register/options', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({username})
});
const options = await response.json();
// Convert challenge and user.id to Uint8Array
options.publicKey.challenge = Uint8Array.from(atob(options.publicKey.challenge), c => c.charCodeAt(0));
options.publicKey.user.id = Uint8Array.from(atob(options.publicKey.user.id), c => c.charCodeAt(0));
const credential = await navigator.credentials.create(options);
const clientDataJSON = btoa(String.fromCharCode(...new Uint8Array(credential.response.clientDataJSON)));
const attestationObject = btoa(String.fromCharCode(...new Uint8Array(credential.response.attestationObject)));
await fetch('/register/verify', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
username,
credential: {
id: credential.id,
rawId: credential.rawId,
type: credential.type,
clientDataJSON,
attestationObject
}
})
});
alert('Passkey registered successfully!');
}
Notice the conversion between base64url and Uint8Array—WebAuthn expects binary data, while JSON transports text. The helper btoa/atob pair does the heavy lifting.
Authentication Flow
// static/login.js
async function startLogin() {
const username = document.getElementById('username').value;
const resp = await fetch('/login/options', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({username})
});
const options = await resp.json();
options.publicKey.challenge = Uint8Array.from(atob(options.publicKey.challenge), c => c.charCodeAt(0));
options.publicKey.allowCredentials = options.publicKey.allowCredentials.map(cred => ({
...cred,
id: Uint8Array.from(atob(cred.id), c => c.charCodeAt(0))
}));
const assertion = await navigator.credentials.get(options);
const clientDataJSON = btoa(String.fromCharCode(...new Uint8Array(assertion.response.clientDataJSON)));
const authenticatorData = btoa(String.fromCharCode(...new Uint8Array(assertion.response.authenticatorData)));
const signature = btoa(String.fromCharCode(...new Uint8Array(assertion.response.signature)));
const userHandle = btoa(String.fromCharCode(...new Uint8Array(assertion.response.userHandle)));
const verifyResp = await fetch('/login/verify', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
username,
credential: {
id: assertion.id,
rawId: assertion.rawId,
type: assertion.type,
clientDataJSON,
authenticatorData,
signature,
userHandle
}
})
});
const result = await verifyResp.json();
if (result.status === 'ok') {
alert('Login successful!');
} else {
alert('Authentication failed.');
}
}
The server side for /login/options and /login/verify mirrors the registration flow but validates the signature against the stored public key.
Real‑World Use Cases
Passkeys are not just a novelty; they solve concrete problems across industries.
- Enterprise SSO: Large organizations can replace password vaults with device‑bound credentials, reducing phishing risk and support tickets.
- Consumer Apps: Social platforms, banking, and e‑commerce sites benefit from frictionless sign‑ups—users can authenticate with a fingerprint or face ID instantly.
- IoT Device Provisioning: When onboarding a smart lock or thermostat, a passkey ties the device to the owner’s account without ever exposing a secret.
Because the protocol is open and supported by major browsers (Chrome, Edge, Safari, Firefox) and operating systems (iOS, Android, Windows Hello), you can implement a single flow that works across desktop, mobile, and embedded devices.
Pro Tips for Production
Never store raw challenges in the session. Instead, hash them with a server‑side secret and compare the hash on verification. This prevents session hijacking from leaking usable challenges.
Use a dedicated WebAuthn library. While the demo shows the mechanics, libraries likewebauthnfor Python or@simplewebauthn/serverfor Node handle CBOR parsing, attestation verification, and credential management securely.
Support multiple authenticators. Not every user has a platform authenticator; allow external security keys (YubiKey, Solo) by settingauthenticatorSelection.authenticatorAttachmentto"any"during registration.
Finally, always enforce user verification (biometrics or PIN) on both registration and authentication. Skipping it reduces security to “something you have” instead of “something you are.”
Conclusion
Passkeys bring passwordless authentication from research labs to everyday browsers, offering a seamless yet robust security model. By following the steps above—generating credential options, handling attestation, and verifying assertions—you can embed this modern flow into any Python or JavaScript stack. Remember to lean on battle‑tested libraries, keep your challenges fresh, and support a variety of authenticators to reach the broadest audience. With passkeys, you’ll not only boost user experience but also dramatically cut down on phishing, credential stuffing, and the endless cycle of password resets. Happy coding, and welcome to the future of authentication!