Native Passkeys in Adobe ColdFusion 2025 - Part 2: Authenticating Users with Passkeys

Registration Was the Easy Part

In part one, we registered a passkey for an authenticated user. That was an important milestone. We created a credential, stored its public key and successfully avoided writing our own cryptographic implementation. Now we need to let someone sign in with it.

This is where the passkey stops being an interesting account-setting feature and becomes part of the authentication system. A successful ceremony will eventually result in an application session, access to private information and perhaps permission to perform actions somebody will later describe to a lawyer.

The stakes have increased slightly.

The authentication ceremony works like this:

  1. ColdFusion creates a unique challenge.
  2. The browser asks the user to select and unlock a passkey.
  3. The authenticator signs the challenge using the private key.
  4. ColdFusion's passkey service verifies the signature using the stored public key.
  5. ColdFusion returns a short-lived result token.
  6. Our application resolves the result to a real user.
  7. Our application decides whether that user is allowed to sign in.
  8. Our application rotates the session identifier and establishes the authenticated session.

ColdFusion handles the Web Authentication protocol. We handle everything that turns a verified credential into an application login. That distinction is going to matter repeatedly. By the end of this article, we’ll have created a successful passkey ceremony that proves control of a credential. It doesn’t automatically create an application session or grant authorization.

Discoverable and Identified Authentication

ColdFusion supports two ways to begin passkey authentication. The first asks for an email address or username before starting the ceremony. The application passes that value to passkeyAuthenticate(), and the browser limits the credential search to that user. The second omits the username. The browser displays the passkeys available for the relying party, and the user selects one.

ColdFusion calls these discoverable credentials. You may also hear them called usernameless authentication. I prefer discoverable. The user isn’t nameless. We merely resisted asking them to type information their device already knows.

The authentication structure for a discoverable ceremony is small:

{
    rpId: "app.example.com",
    userVerification: "preferred"
}

There’s deliberately no username. For an email-first ceremony, it becomes:

{
    username: "ada@example.com",
    rpId: "app.example.com",
    userVerification: "preferred"
}

The current Adobe ColdFusion passkey documentation confirms that omitting username starts discoverable authentication. Discoverable authentication produces the cleaner sign-in experience, so that’s what we’ll build. We’ll retain optional username support because requirements have a habit of returning after being declared dead.

In the service we created in part one, rename the application-owned constructor argument and variable:

public nativePasskeyService function init(
    required string rpName,
    required string rpId,
    string servicePath = "/__cf_passkey/DatabasePasskey.cfc",
    string callbackPath = "/auth/passkey-callback.cfm"
) {
    variables.rpName = trim( arguments.rpName );
    variables.relyingPartyId = lCase( trim( arguments.rpId ) );
    variables.servicePath = arguments.servicePath;
    variables.callbackPath = arguments.callbackPath;

    if ( !len( variables.rpName ) ) {
        throw(
            type = "Passkey.InvalidConfiguration",
            message = "The passkey relying party name is required."
        );
    }

    if ( !len( variables.rpId ) ) {
        throw(
            type = "Passkey.InvalidConfiguration",
            message = "The passkey relying party identifier is required."
        );
    }

    return this;
}

Update the service initialization in Application.cfc as well:

<cfscript>
    application.passkeys = new models.NativePasskeyService(
        rpName = "Example Application",
        rpId = "app.example.com",
        servicePath = "/__cf_passkey/DatabasePasskey.cfc",
        callbackPath = "/auth/passkey-callback.cfm"
    );
</cfscript>

The registration structure from part one should now map our full application-owned name to ColdFusion's required property:

return {
    name: arguments.user.email,
    displayName: displayName,
    id: toString( arguments.user.id ),
    rpName: variables.rpName,
    rpId: variables.rpId,
    userVerification: "preferred",
    authenticatorAttachment: "platform",
    attestation: "none"
};

Now add this method to models/nativePasskeyService.cfc:

public struct function buildAuthenticationUser(
    string username = ""
) {
    var authenticationUser = {
        rpId: variables.relyingPartyId,
        userVerification: "preferred"
    };

    if ( len( trim( arguments.username ) ) ) {
        authenticationUser.username = trim( arguments.username );
    }

    return authenticationUser;
}

The relying party identifier comes from the server-controlled service configuration created in part one. It doesn’t come from a form field or request header. Passing no username gives us discoverable authentication:

authenticationUser = application.passkeys.buildAuthenticationUser();

Passing a username gives us an identified ceremony:

authenticationUser = application.passkeys.buildAuthenticationUser( username = form.email );

Even in the second version, the supplied email address is only a credential-selection hint. We won’t use it to decide which user receives the authenticated session. The result of the signed ceremony will make that decision.

Code takeaway: Omit username for discoverable authentication. If you include it, treat it as a hint rather than proof of identity.

Adding an Authentication Error Handler

In part one, our configuration contained the callback path and the application-local passkey service path. Authentication gives us another reason to include an explicit browser error handler. The user might cancel, choose a passkey that no longer has a corresponding server credential or attempt the ceremony on an unsupported device. Replace buildConfig() in models/nativePasskeyService.cfc with this version:

public struct function buildConfig() {
    return {
        redirectUrl: variables.callbackPath,
        service: variables.servicePath,
        errorHandler: "handleNativePasskeyError"
    };
}

handleNativePasskeyError is the name of a JavaScript function we’ll define on the ceremony page. The function name is fixed by our application. It isn’t accepted from the request. This follows the same rule as the callback and service paths: configuration that controls an authentication flow belongs to the server.

Code takeaway: Give ColdFusion a fixed browser error-handler name. Never allow request data to choose executable JavaScript or redirect destinations.

Starting Discoverable Authentication

Create auth/passkey-signin.cfm:

<cfscript>
    if ( !application.passkeys.isSupported() ) {
        location(
            url = "/signin.cfm" & "?passkey=unsupported",
            addToken = false
        );
    }

    session.pendingPasskeyAuthentication = {
        createdAt: now(),
        returnPath: "/account/index.cfm"
    };

    request.passkeyAction = "authentication";

    try {
        PasskeyAuthenticate(
            application.passkeys.buildAuthenticationUser(),
            application.passkeys.buildConfig()
        );
    }
    catch ( any error ) {
        structDelete( session, "pendingPasskeyAuthentication" );

        writeLog(
            type = "error",
            file = "authentication",
            text = "PASSKEY_AUTHENTICATION_START_FAILED" & " type=#error.type ?: ''#" & " message=#error.message ?: ''#"
        );

        location(
            url = "/signin.cfm" & "?passkey=start_failed",
            addToken = false
        );
    }

    include "../includes/passkey-ceremony.cfm"
</cfscript>

There are three important decisions in this file. First, we refuse to start if the installed ColdFusion engine doesn’t support the native passkey functions. Second, we create a pending authentication record in the session. The record doesn’t contain a user identifier because we don’t know the user yet. It proves only that this browser session initiated an authentication ceremony recently. Third, the return path is fixed by the application. We aren’t accepting something like this:

returnPath=https://definitely-not-crime.example

Authentication systems don’t need help redirecting users into danger. They’re perfectly capable of producing danger locally. The pending record gives the callback something to require before it creates a session. ColdFusion already binds its ceremony to the session through its own protection against cross-site request forgery. Our pending record adds application-level intent:

This session started a passkey authentication ceremony.

That’s different from merely receiving a valid-looking callback address.

Code takeaway: Record the authentication attempt before calling passkeyAuthenticate(). Keep the return destination fixed or select it from a strict server-side allowlist.

Reusing the Browser Ceremony

The browser code from part one called CFPasskey.startRegistration(). We now need the same page to support both registration and authentication. Replace includes/passkey-ceremony.cfm with this version:

<cfscript>
    passkeyAction = request.passkeyAction ?: "registration";

    if ( !listFindNoCase( "registration,authentication", passkeyAction ) ) {
        passkeyAction = "registration";
    }
</cfscript>

<cfoutput>
    <div id="passkey-status" role="status">Waiting for your device…</div>
    <div id="passkey-error" role="alert" hidden>We couldn''t complete the passkey request.</div>

    <p>
        <a href="/signin.cfm">Use another sign-in method</a>
    </p>

    <script>
        (function () {
            "use strict";

            var action =
                "#encodeForJavaScript( passkeyAction )#";

            var status = document.getElementById( "passkey-status" );
            var error = document.getElementById( "passkey-error" );
            var attempts = 0;
            var started = false;
            var finished = false;
            var backstopTimer = null;

            function finishWithError( message ) {
                if (finished) { return; }
                finished = true;

                if (backstopTimer) {
                    window.clearTimeout( backstopTimer );
                }

                status.hidden = true;
                error.textContent = message;
                error.hidden = false;
                error.setAttribute( "tabindex", "-1" );
                error.focus();
            }

            function classifyError( reason ) {
                var name = reason && reason.name ? String(reason.name) : "";
                var code = reason && reason.code ? String(reason.code) : "";

                if ( name === "NotAllowedError" || name === "AbortError" ) {
                    return "Passkey authentication was cancelled.";
                }

                if ( code === "NOT_SUPPORTED" ) {
                    return "This browser or device doesn't support passkeys.";
                }

                if ( code === "AUTH_FAILED" ) {
                    return "We couldn't verify that passkey. It may have been removed or replaced.";
                }

                return "We couldn't complete the passkey request.";
            }

            window.handleNativePasskeyError =
                function (errorData) {
                    finishWithError( classifyError( errorData ) );
                };

            function startCeremony() {
                if ( started || finished ) {
                    return;
                }

                if ( !window.PublicKeyCredential ) {
                    finishWithError( "This browser doesn't support passkeys." );
                    return;
                }

                if ( !window.CFPasskey || !window.CFPasskey._config || !window.CFPasskey._config.action ) {
                    attempts++;

                    if (attempts > 60) {
                        finishWithError( "The passkey service is unavailable." );
                        return;
                    }

                    window.setTimeout( startCeremony, 200 );
                    return;
                }

                var ceremonyFunction = action === "authentication" ? window.CFPasskey.startAuthentication : window.CFPasskey.startRegistration;

                if ( typeof ceremonyFunction !== "function" ) {
                    finishWithError( "Passkey authentication is unavailable." );
                    return;
                }

                started = true;

                /*
                 * This catches a ceremony
                 * that never redirects and
                 * never reports an error.
                 */
                backstopTimer = window.setTimeout(
                    function () { finishWithError( "The passkey request didn't finish. Please try again." ); },
                    120000
                );

                try {
                    var ceremony = ceremonyFunction.call( window.CFPasskey );

                    if ( ceremony && typeof ceremony.catch === "function" ) {
                        ceremony.catch(
                            function (reason) { finishWithError( classifyError( reason ) ); }
                        );
                    }
                }
                catch (reason) { finishWithError( classifyError( reason ) ); }
            }

            window.addEventListener(
                "beforeunload",
                function () {
                    finished = true;

                    if (backstopTimer) { window.clearTimeout( backstopTimer ); }
                }
            );

            window.setTimeout( startCeremony, 200 );
        })();
    </script>
</cfoutput>

The page selects one of two ColdFusion functions:

window.CFPasskey.startRegistration

or:

window.CFPasskey.startAuthentication

Everything else is shared. The error handler distinguishes cancellation, lack of browser support and an authentication failure. It deliberately doesn’t display ColdFusion's raw server response. Raw authentication errors are written for developers, not users. They can reveal implementation details and are often much less helpful than their creators hoped.

The two-minute timer is a backstop, not an expected ceremony duration. We don’t use a short timer after the service responds because the user may still be interacting with the operating-system prompt.

Some people select a passkey immediately. Others read every word, question the nature of identity and then remember they left their phone downstairs. The browser should be allowed to wait for them.

Code takeaway: Share the ceremony page, choose the ColdFusion function from a server-controlled action and provide a bounded recovery path when the browser helper neither redirects nor reports an error.

Processing Both Callback Actions

ColdFusion redirects both registration and authentication to the callback configured in buildConfig(). The result tells us which operation completed:

result.action

A single callback can handle both operations, but it must verify the action before doing anything useful. Replace auth/passkey-callback.cfm with the following:

<cfscript>
    param name = "url.passkey_token" default = "";
    result = application.passkeys.interpretResult( url.passkey_token );

    if ( !result.success ) {
        structDelete( session, "pendingPasskeyAuthentication" );
        location( url = "/signin.cfm" & "?passkey=failed", addToken = false );
    }

    if ( result.action == "registration" ) {
        pendingRegistration = session.pendingPasskeyRegistration ?: {};

        structDelete( session, "pendingPasskeyRegistration" );

        if ( structIsEmpty( pendingRegistration ) ) {
            location( url = "/account/security.cfm" & "?passkey=invalid_state", addToken = false );
        }

        if (
            !isDate( pendingRegistration.createdAt )
            || dateDiff( "s", pendingRegistration.createdAt, now() ) > 300
        ) {
            location( url = "/account/security.cfm" & "?passkey=expired", addToken = false );
        }

        if (
            compareNoCase(
                result.userId,
                toString(
                    pendingRegistration.userId
                )
            ) != 0
        ) {
            writeLog(
                type = "error",
                file = "authentication",
                text =
                    "PASSKEY_REGISTRATION_USER_MISMATCH"
            );

            location(
                url =
                    "/account/security.cfm"
                    & "?passkey=user_mismatch",
                addToken = false
            );
        }

        location(
            url =
                "/account/security.cfm"
                & "?passkey=registered",
            addToken = false
        );
    }

    if (
        result.action
        != "authentication"
    ) {
        location(
            url =
                "/signin.cfm"
                & "?passkey=invalid_action",
            addToken = false
        );
    }

    pendingAuthentication = session.pendingPasskeyAuthentication ?: {};
    structDelete( session, "pendingPasskeyAuthentication" );

    if ( structIsEmpty( pendingAuthentication ) ) {
        location( url = "/signin.cfm" & "?passkey=invalid_state", addToken = false );
    }

    if (
        !isDate( pendingAuthentication.createdAt )
        || dateDiff( "s", pendingAuthentication.createdAt, now() ) > 300
    ) {
        location( url = "/signin.cfm" & "?passkey=expired", addToken = false );
    }

    user = application.users.findActiveById( result.userId );

    if ( !isStruct( user ) || structIsEmpty( user ) ) {
        writeLog(
            type = "warning",
            file = "authentication",
            text = "PASSKEY_AUTHENTICATION_USER_UNAVAILABLE"
        );

        location( url = "/signin.cfm" & "?passkey=user_unavailable", addToken = false );
    }

    sessionRotate();
    session.signedIn = true;

    session.user = {
        id: user.id,
        email: user.email,
        firstName: user.firstName,
        lastName: user.lastName,
        roles: user.roles
    };

    writeLog(
        type = "information",
        file = "authentication",
        text = "PASSKEY_AUTHENTICATION_COMPLETED" & " user_id=#user.id#"
    );

    location( url = pendingAuthentication .returnPath, addToken = false );
</cfscript>

There’s quite a lot happening here, so let’s separate proof from policy. ColdFusion proves that the credential successfully signed the challenge. passkeyGetResult() gives us the internal user identifier associated with that credential. Our application then applies policy:

  • Was an authentication ceremony pending in this session?
  • Is it still recent?
  • Did the result report an authentication action?
  • Does the user still exist?
  • Is the user still active?
  • What roles and permissions does the user currently have?
  • Where should the user go after signing in?

We load the user by result.userId. We don’t load the user by result.username, an email address from a form or an account name remembered from three requests ago. The internal user identifier is the stable value we registered in part one. The username is useful for display and credential lookup, but it isn’t the primary authority for our application session.

The user lookup must also apply the application’s current account rules. A valid passkey for a suspended or deleted user is still a valid cryptographic credential. It isn’t permission to ignore the application database. Cryptography can confirm identity. It can’t determine whether accounting finally got around to disabling Steve.

Code takeaway: Resolve the authenticated user from the stable internal identifier returned by ColdFusion, then reapply current account status and authorization from your own database.

Loading the Application User

The callback assumes an application service named application.users. Your application probably already has something equivalent. If not, this intentionally plain example provides the required contract. Create models/UserService.cfc:

component output="false" {

    public UserService function init( required string datasource ) {
        variables.datasource = arguments.datasource;
        return this;
    }


    public struct function findActiveById( required string userId ) {
        var users = queryExecute(
            sql = "
                SELECT
                    id,
                    email,
                    first_name,
                    last_name,
                    roles
                FROM
                    users
                WHERE
                    id = :userId
                    AND active = 1
            ",
            params = { userId: { value: arguments.userId, cfsqltype: "varchar" } },
            options = { datasource: variables.datasource, returnType: "array" }
        );

        if ( !arrayLen( users ) ) { return {} }

        return {
            id: toString( users[ 1 ].id ),
            email: toString( users[ 1 ].email ),
            firstName: toString( users[ 1 ].first_name ),
            lastName: toString( users[ 1 ].last_name ),
            roles: toString( users[ 1 ].roles )
        };
    }
}

Initialize it beside the passkey service in Application.cfc:

<cfscript>
    application.users = new models.UserService( datasource = "passkey_demo" );
</cfscript>

Adapt the table, column names and role-loading logic to your application. Don’t accept authorization information from the passkey result. ColdFusion didn’t assign the user’s application roles when the credential was registered, and those roles may have changed since then. Authentication tells us who the user is. Authorization tells us what the user may do now. Mixing those together is how a former administrator keeps administrative access until somebody notices during an incident review.

Code takeaway: Load current roles and account status from the application database after authentication. Never store long-lived authorization decisions inside the passkey credential.

Rotating the Session Identifier

The callback calls:

sessionRotate();

This isn’t decorative. The anonymous browser already had a ColdFusion session before authentication began. If we simply mark that existing session as signed in, an attacker who caused or learned that session identifier may be able to reuse it. Rotating the session identifier after authentication reduces that session-fixation risk while preserving the session data ColdFusion needs. Rotate first, then write the authenticated user:

sessionRotate();
session.signedIn = true;
session.user = {
    id: user.id,
    email: user.email,
    roles: user.roles
};

You should perform the same rotation after password, email-link and external identity-provider authentication. Passkeys aren’t uniquely deserving of sensible session hygiene. They’re merely the authentication method that reminded us to look.

Code takeaway: Call sessionRotate() after successful authentication and before treating the session as authenticated.

Handling Missing and Stale Credentials

Passkeys exist in two places. The user’s device or password manager holds the private key. Our server holds the public credential record. Those records can become separated.

A user might remove the passkey from their device while the server record remains. They might remove the server record while a synchronized copy remains on another device. They might restore an old device backup, migrate password managers or perform some other perfectly reasonable act that transforms our tidy authentication model into folklore.

When the browser presents a credential that the server can’t verify, ColdFusion reports an authentication failure. We should tell the user what to do next:

We couldn’t verify that passkey. It may have been removed or replaced. Try another passkey or use another sign-in method.

We shouldn’t automatically delete credentials after one failed attempt. The failure might have been caused by cancellation, a temporary service problem, an origin mismatch or a reverse proxy enjoying a brief creative period. Provide recovery instead:

  • Try another passkey.
  • Sign in with a password or email link.
  • Register a replacement passkey after authentication.
  • Remove obsolete passkeys from account security settings.

A passkey failure shouldn’t strand the account.

Code takeaway: Treat authentication failure as recoverable. Don’t delete credentials automatically, and always retain a separately secured account-recovery method.

Listing a User’s Passkeys

ColdFusion’s DatabasePasskey component creates and owns the passkey_credentials table. ColdFusion provides component methods for user-level credential lookup and deletion, but it doesn’t provide a complete application-facing workflow for naming and removing one selected credential. If we want targeted account management, we need a small adapter around the generated table or a custom passkey storage component.

Directly depending on an ColdFusion-managed table isn’t ideal. Keep that dependency behind one component and test it after every ColdFusion update. Create models/PasskeyCredentialService.cfc:

component output="false" {

    public PasskeyCredentialService function init( required string datasource ) {
        variables.datasource = arguments.datasource;
        return this;
    }


    public array function listForUser( required string userId ) {
        var rows = queryExecute(
            sql = "
                SELECT
                    credential_id,
                    display_name,
                    authenticator_attachment,
                    created_at,
                    last_used_at
                FROM
                    passkey_credentials
                WHERE
                    username = :userId
                ORDER BY
                    created_at
            ",
            params = { userId: { value: arguments.userId, cfsqltype: "varchar" } },
            options = { datasource: variables.datasource, returnType: "array" }
        );

        var credentials = [];

        for ( var row in rows ) {
            arrayAppend( credentials {
                handle: lCase( hash( row.credential_id, "SHA-256" ) ),
                label: len( trim( row.display_name ?: "" ) ) ? trim( row.display_name ) : "Passkey",
                attachment: toString( row.authenticator_attachment ?: "" ),
                createdAt: isNull( row.created_at ) ? "" : dateTimeFormat( row.created_at, "yyyy-mm-dd HH:nn" ),
                lastUsedAt: isNull( row.last_used_at ) ? "" : dateTimeFormat( row.last_used_at, "yyyy-mm-dd HH:nn" )
            } );
        }

        return credentials;
    }
}

The browser never receives the stored credential_id. Instead, it receives a one-way hash named handle. That handle allows the browser to refer to one row without exposing the actual credential identifier. This is defence in depth. Credential identifiers aren’t private keys, but they’re still authentication material. There’s no benefit in scattering them through markup, analytics and browser extensions like complimentary mints. Initialize the service in Application.cfc:

<cfscript>
    application.passkeyCredentials = new models.PasskeyCredentialService( datasource = "passkey_demo" );
</cfscript>

Load the credentials for the authenticated user:

<cfscript>
    passkeys = application.passkeyCredentials .listForUser( session.user.id );
    removeToken = CSRFGenerateToken( "remove-passkey", true );
</cfscript>

Render them without exposing internal identifiers:

<cfoutput>
    <h1>Your passkeys</h1>

    <cfif !arrayLen( passkeys )>
        <p>You haven't registered a passkey.</p>
    <cfelse>
        <ul>
            <cfloop array="#passkeys#" index="passkey">
                <li>
                    <strong>#encodeForHTML(passkey.label)#</strong>

                    <cfif len( passkey.lastUsedAt )>
                        <span>Last used #encodeForHTML( passkey.lastUsedAt )#</span>
                    </cfif>

                    <form method="post" action="/account/remove-passkey.cfm">
                        <input type="hidden" name="handle" value="#encodeForHTMLAttribute( passkey.handle )#">
                        <input type="hidden" name="csrf_token" value="#encodeForHTMLAttribute( removeToken )#">
                        <button type="submit">Remove passkey</button>
                    </form>
                </li>
            </cfloop>
        </ul>
    </cfif>
</cfoutput>

The query uses username because ColdFusion's database backend stores the stable registration name there. In our implementation, that value is the internal user identifier supplied during registration. Confirm this behaviour against the ColdFusion version you deploy. This is precisely why the table access belongs in one adapter rather than twenty-seven account templates and something called final-passkey-fix.cfm.

Code takeaway: Return a derived handle to the browser, not the stored credential identifier. Isolate every dependency on ColdFusion’s generated table behind one component.

Removing One Passkey Safely

Add this method to passkeyCredentialService.cfc:

public boolean function removeForUser( required string userId, required string handle ) {
    var requestedHandle = lCase( trim( arguments.handle ) );
    if ( !len( requestedHandle ) ) { return false; }

    var rows = queryExecute(
        sql = "
            SELECT credential_id
            FROM passkey_credentials
            WHERE username = :userId
        ",
        params = { userId: { value: arguments.userId, cfsqltype: "varchar" } },
        options = { datasource: variables.datasource, returnType: "array" }
    );

    var credentialId = "";

    for ( var row in rows ) {
        var candidateHandle = lCase( hash( row.credential_id, "SHA-256" ) );

        if ( candidateHandle == requestedHandle ) {
            credentialId = row.credential_id;
            break;
        }
    }

    if ( !len( credentialId ) ) { return false; }

    var deleteResult = {};
    queryExecute(
        sql = "
            DELETE FROM passkey_credentials
            WHERE username = :userId
                AND credential_id = :credentialId
        ",
        params = {
            userId: { value: arguments.userId, cfsqltype: "varchar" },
            credentialId: { value: credentialId, cfsqltype: "varchar" }
        },
        options = { datasource: variables.datasource, result: "deleteResult" }
    );

    return ( deleteResult.recordCount ?: 0 ) == 1;
}

The deletion query is scoped by both values:

The authenticated user identifier
and
the server-resolved credential identifier

A posted handle belonging to another user won’t match the initial lookup. Even if it somehow reached the deletion query, the user condition would still prevent the other row from being removed. Now create account/remove-passkey.cfm:

<cfscript>
    param name = "form.handle" default = "";
    param name = "form.csrf_token" default = "";

    if (
        !structKeyExists( session, "signedIn" )
        || !session.signedIn
        || !structKeyExists( session, "user" )
    ) {
        location( url = "/signin.cfm", addToken = false );
    }

    if ( !CSRFVerifyToken( form.csrf_token, "remove-passkey" ) ) {
        location( url = "/account/security.cfm" & "?passkey=invalid_request", addToken = false );
    }

    removed = application.passkeyCredentials.removeForUser(
        userId = session.user.id,
        handle = form.handle
    );

    writeLog(
        type = "information",
        file = "authentication",
        text = "PASSKEY_REMOVAL_COMPLETED" & " user_id=#session.user.id#" & " removed=#removed#"
    );

    location( url = "/account/security.cfm" & ( removed ? "?passkey=removed" : "?passkey=not_found" ), addToken = false );
</cfscript>

The endpoint requires:

  • An authenticated session
  • A valid token protecting against cross-site request forgery
  • A handle that resolves to a credential belonging to the authenticated user
  • A parameterized deletion query that repeats the user restriction

If passkeys are the user’s only sign-in method, add another rule before deletion. Require a recovery method, a second passkey or a recent reauthentication ceremony. Our example assumes the application still offers another recovery method. Removing the server record prevents that passkey from authenticating here. It doesn’t necessarily remove the private credential from the user’s device or password manager. Tell the user they may need to remove that copy separately. Deleting half of a relationship and pretending the other half received the memo is, unfortunately, common in both distributed systems and human resources.

Code takeaway: Protect removal with authentication, cross-site request forgery validation and user-scoped deletion. Don’t allow users to remove their final recovery path accidentally.

Testing the Authentication Structure

We can test the server-controlled authentication structure without operating a real authenticator. Update the service construction in tests/NativePasskeyServiceSpec.cfc:

variables.service = new models.NativePasskeyService( rpName = "Example Application", relyingPartyId = "app.example.com" );

Then add these specifications:

it(
    "builds discoverable authentication without a username",
    function() {
        var result = variables.service.buildAuthenticationUser();
        expect( result[ "rpId" ] ).toBe( "app.example.com" );
        expect( result.userVerification ).toBe( "preferred" );
        expect( structKeyExists( result, "username" ) ).toBeFalse();
    }
);

it(
    "adds a username only when one is supplied",
    function() {
        var result = variables.service.buildAuthenticationUser( username = "ada@example.com" );
        expect( result.username ).toBe( "ada@example.com" );
    }
);

it(
    "uses a fixed passkey error handler",
    function() {
        var result = variables.service.buildConfig();
        expect( result.errorHandler ).toBe( "handleNativePasskeyError" );
    }
);

These tests protect decisions that should never drift casually:

  • Discoverable authentication doesn’t send a username.
  • The relying party identifier remains server-controlled.
  • User verification remains explicit.
  • ColdFusion receives the expected fixed error-handler name.

The real ceremony still requires a browser, an authenticator and a secure connection. Unit testing has limits. It can’t press your fingerprint sensor, inspect your reverse proxy or explain why one particular laptop believes it’s still Tuesday.

Code takeaway: Test the structures you hand to ColdFusion. Browser testing should verify the ceremony, while server tests protect your configuration contract.

Testing the Complete Sign-In Flow

With the application running over Hypertext Transfer Protocol Secure:

  1. Register a passkey using the flow from part one.
  2. Sign out completely.
  3. Visit /auth/passkey-signin.cfm.
  4. Select the registered passkey.
  5. Complete the device verification prompt.
  6. Confirm that ColdFusion redirects to the callback.
  7. Confirm that the application loads the user by the returned internal identifier.
  8. Confirm that the session identifier changes.
  9. Confirm that the user reaches the fixed authenticated destination.

Don’t stop there. Also test:

  • Cancelling the passkey prompt
  • Waiting until the challenge expires
  • Refreshing the callback page
  • Opening the callback without a token
  • Clearing the session before the callback
  • Attempting to sign in as an inactive user
  • Removing the server credential while leaving the device credential intact
  • Removing the device credential while leaving the server credential intact
  • Registering two passkeys for the same user
  • Trying a passkey registered for another hostname
  • Posting another user’s removal handle
  • Posting a removal form without a valid cross-site request forgery token

A security feature that works only while everyone behaves isn’t a security feature. It’s a stage production.

What We Built

We now have a complete passkey authentication flow:

  • The sign-in page starts discoverable authentication.
  • ColdFusion creates and verifies the Web Authentication challenge.
  • The browser allows the user to select an available passkey.
  • The callback requires a recent pending ceremony.
  • The callback accepts only an authentication result.
  • The application resolves the stable internal user identifier.
  • Current account status and authorization come from the application database.
  • The session identifier rotates before the user becomes authenticated.
  • Users receive recoverable errors when a credential is missing or stale.
  • Users can view and remove their stored passkeys without exposing credential identifiers.

The most important code isn’t the line that calls passkeyAuthenticate(). It’s everything after that line. ColdFusion can tell us that a credential produced a valid signature. Only our application can decide whether the corresponding account still exists, whether it may sign in and what it may do afterward.

In part three, we’ll move registration and authentication to a central authentication origin so several applications can use one passkey identity. That’ll force us to think carefully about relying party boundaries, trusted callbacks and how one application proves another application actually started the request. In other words, we’re taking the authentication flow that now works and introducing architecture.

This has never caused trouble before.