Passkey Registration and Validation Example | PropelAuth BYO Documentation

Passkey Registration and Validation Example

Passkeys let users authenticate with their fingerprint, face scan, or device PIN instead of passwords. This guide walks you through implementing the two essential flows:

PropelAuth BYO handles the backend WebAuthn complexity, but you’ll need a frontend library to interact with the user’s device. This guide uses @simplewebauthn/browser for client-side operations.

Registering a User’s Passkey

Start by creating a backend endpoint that generates registration options. The Start Passkey Registration API returns registrationOptions that tell the browser how to create the passkey (whether to use Google Password Manager, iCloud Keychain, or the device’s built-in authenticator).

Node

app.post("/api/passkey/register/start", async (req: Request, res: Response) => {
    const user = getUserInfoFromRequest(req);
    const result = await auth.passkeys.startRegistration({
        userId: user.userId,
        emailOrUsername: user.email,
    });
    if (result.ok) {
        res.json({ registrationOptions: result.data.registrationOptions });
    } else {
        console.error("Error starting registration:", result.error);
        res.status(500).json({ error: "Failed to start passkey registration" });
    }
});

Python

@app.post("/api/passkey/register/start")
async def passkey_register_start(request: Request):
    user = get_user_info_from_request(request)
    result = await client.passkeys.start_registration(
        user_id=user.user_id,
        email_or_username=user.email,
    )
    if is_ok(result):
        return {"registrationOptions": result.data.registration_options}
    else:
        print("Error starting registration:", result.error)
        raise HTTPException(status_code=500, detail="Failed to start passkey registration")

Go

mux.HandleFunc("POST /api/passkey/register/start", func(w http.ResponseWriter, r *http.Request) {
    user := getUserInfoFromRequest(r)
    result, err := client.Passkeys.StartRegistration(r.Context(), byo.StartPasskeyRegistrationCommand{
        UserID:          user.UserID,
        EmailOrUsername: user.Email,
    })
    if err != nil {
        log.Println("Error starting registration:", err)
        http.Error(w, "Failed to start passkey registration", http.StatusInternalServerError)
        return
    }
    json.NewEncoder(w).Encode(map[string]any{"registrationOptions": result.RegistrationOptions})
})

Next, create a frontend component that triggers the registration. When clicked, it fetches the registrationOptions from your backend and passes them to @simplewebauthn/browser, which handles the device prompt.

import { startRegistration } from '@simplewebauthn/browser';

const EnrollPasskey = () => {
    const handleSubmit = async (e: React.FormEvent) => {
        e.preventDefault();
        try {
            const startResponse = await fetch('/api/passkey/register/start', {
                method: 'POST',
                headers: {'Content-Type': 'application/json',}
            });
            const { registrationOptions } = await startResponse.json();
            const publicKey = await startRegistration({ optionsJSON: registrationOptions, });
        } catch (error) {
            console.error('Error creating passkey:', error);
        }
    };
    return (<div><button onClick={handleSubmit}>Enroll a Passkey</button></div>);
};
export default EnrollPasskey;

Clicking the button prompts the browser to create a passkey:

After the user creates the passkey, you need to save it. Add a backend endpoint that accepts the credential and calls Finish Passkey Registration to complete the process.

Node

app.post("/api/passkey/register/finish", async (req, res) => {
    const user = getUserInfoFromRequest(req);
    const result = await auth.passkeys.finishRegistration({
        userId: user.userId,
        publicKey: req.body.publicKey,
    });
    if (result.ok) {
        res.json({ success: true, data: result.data });
    } else {
        console.error("Error finishing registration:", result.error);
        res.status(500).json({ error: "Failed to complete passkey registration" });
    }
});

Python

@app.post("/api/passkey/register/finish")
async def passkey_register_finish(request: Request):
    user = get_user_info_from_request(request)
    body = await request.json()
    result = await client.passkeys.finish_registration(
        user_id=user.user_id,
        public_key=body["publicKey"],
    )
    if is_ok(result):
        return {"success": True, "data": result.data}
    else:
        print("Error finishing registration:", result.error)
        raise HTTPException(status_code=500, detail="Failed to complete passkey registration")

Go

mux.HandleFunc("POST /api/passkey/register/finish", func(w http.ResponseWriter, r *http.Request) {
    user := getUserInfoFromRequest(r)
    var body struct {
        PublicKey json.RawMessage `json:"publicKey"`
    }
    if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
        http.Error(w, "Invalid request body", http.StatusBadRequest)
        return
    }
    result, err := client.Passkeys.FinishRegistration(r.Context(), byo.FinishPasskeyRegistrationCommand{
        UserID:    user.UserID,
        PublicKey: body.PublicKey,
    })
    if err != nil {
        log.Println("Error finishing registration:", err)
        http.Error(w, "Failed to complete passkey registration", http.StatusInternalServerError)
        return
    }
    json.NewEncoder(w).Encode(map[string]any{"success": true, "data": result})
})

The last step is updating our frontend to send the credential to this new endpoint. Once successful, users can register passkeys for use during MFA, login, and more.

import { startRegistration } from '@simplewebauthn/browser';

const EnrollPasskey = () => {
    const handleSubmit = async (e: React.FormEvent) => {
        e.preventDefault();
        try {
            const startResponse = await fetch('/api/passkey/register/start', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json', },
            });
            const { registrationOptions } = await startResponse.json();
            const publicKey = await startRegistration({ optionsJSON: registrationOptions, });
            const finishResponse = await fetch("/api/passkey/register/finish", {
                method: "POST",
                headers: { "Content-Type": "application/json" },
                body: JSON.stringify({ publicKey: publicKey, }),
            });
            if (!finishResponse.ok) {
                const error = await finishResponse.json();
                throw new Error(error.error || "Failed to finish registration");
            }
            console.log('Passkey registered successfully!');
        } catch (error) {
            console.error('Error creating passkey:', error);
        }
    };
    return (<div><button onClick={handleSubmit}>Enroll a Passkey</button></div>);
};
export default EnrollPasskey;

Now let’s implement passkey authentication.

Authenticating with Passkeys

With registration complete, let’s build the authentication flow. Create a backend endpoint that uses Start Passkey Authentication to generate authenticationOptions for the frontend:

Node

app.post("/api/passkey/authenticate/start", async (req: Request, res: Response) => {
    const user = getUserInfoFromRequest(req);
    const result = await auth.passkeys.startAuthentication({
        userId: user.userId
    });
    if (result.ok) {
        res.json({ authenticationOptions: result.data.authenticationOptions });
    } else {
        console.error("Error starting authentication:", result.error);
        res.status(500).json({ error: "Failed to start passkey authentication" });
    }
});

Python

@app.post("/api/passkey/authenticate/start")
async def passkey_authenticate_start(request: Request):
    user = get_user_info_from_request(request)
    result = await client.passkeys.start_authentication(
        user_id=user.user_id
    )
    if is_ok(result):
        return {"authenticationOptions": result.data.authentication_options}
    else:
        print("Error starting authentication:", result.error)
        raise HTTPException(status_code=500, detail="Failed to start passkey authentication")

Go

mux.HandleFunc("POST /api/passkey/authenticate/start", func(w http.ResponseWriter, r *http.Request) {
    user := getUserInfoFromRequest(r)
    result, err := client.Passkeys.StartAuthentication(r.Context(), byo.StartPasskeyAuthenticationCommand{
        UserID: user.UserID,
    })
    if err != nil {
        log.Println("Error starting authentication:", err)
        http.Error(w, "Failed to start passkey authentication", http.StatusInternalServerError)
        return
    }
    json.NewEncoder(w).Encode(map[string]any{"authenticationOptions": result.AuthenticationOptions})
})

On the frontend, let’s create a button that triggers the passkey authentication flow. First, we’ll request the authenticationOptions from our backend, then pass these to @simplewebauthn/browser which prompts the user to authenticate with their passkey.

import { startAuthentication } from '@simplewebauthn/browser';

const ValidatePasskey = () => {
    const handleSubmit = async (e: React.FormEvent) => {
        e.preventDefault();
        try {
            const startResponse = await fetch('/api/passkey/authenticate/start', {
                method: 'POST',
                headers: {'Content-Type': 'application/json',}
            });
            const { authenticationOptions } = await startResponse.json();
            const publicKey = await startAuthentication({ optionsJSON: authenticationOptions, });
        } catch (error) {
            console.error('Error validating passkey:', error);
        }
    };
    return (<div><button onClick={handleSubmit}>Validate a Passkey</button></div>);
};
export default ValidatePasskey;

The last step is updating our frontend to send the credential to this new endpoint. If successful, your users can authenticate with passkeys for MFA, login, and more.

import { startAuthentication } from '@simplewebauthn/browser';

const AuthenticateWithPasskey = () => {
    const handleSubmit = async (e: React.FormEvent) => {
        e.preventDefault();
        try {
            const startResponse = await fetch('/api/passkey/authenticate/start', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json', },
            });
            const { authenticationOptions } = await startResponse.json();
            const publicKey = await startAuthentication({ optionsJSON: authenticationOptions, });
            const finishResponse = await fetch("/api/passkey/authenticate/finish", {
                method: "POST",
                headers: { "Content-Type": "application/json" },
                body: JSON.stringify({ publicKey: publicKey, }),
            });
            if (!finishResponse.ok) {
                const error = await finishResponse.json();
                throw new Error(error.error || "Failed to finish authentication");
            }
            const result = await finishResponse.json();
            console.log("Authentication successful:", result);
        } catch (error) {
            console.error('Error authenticating with passkey:', error);
        }
    };
    return (<div><button onClick={handleSubmit}>Authenticate with Passkey</button></div>);
};
export default AuthenticateWithPasskey;

And we’re done! We’ve successfully implemented both passkey registration and authentication flows.