cbsecurity-passkeys

Use this skill when implementing WebAuthn/Passkeys authentication in ColdBox/BoxLang using the cbsecurity-passkeys module. Covers the credential repository interface, registration ceremony, authentication ceremony, JavaScript integration, and production hardening for passwordless login.

CBSecurity Passkeys Skill

When to Use This Skill

Load this skill when:

  • Adding WebAuthn / Passkeys (passwordless) authentication to a ColdBox application
  • Implementing the ICredentialRepository interface for custom credential storage
  • Building the registration and authentication ceremony endpoints
  • Connecting the WebAuthn JS client to ColdBox handlers
  • Hardening passkey flows for production security

Installation

box install cbsecurity-passkeys

Requires cbsecurity to be installed and configured:

box install cbsecurity

Configuration

config/modules/cbsecurity-passkeys.cfc

function configure() {
    return {
        // WireBox ID of your ICredentialRepository implementation
        credentialRepository : "PasskeyCredentialRepository",

        // Relying Party name shown to users during registration
        rpName : "My Application",

        // Relying Party ID — must match the effective domain of your app
        rpId   : getSystemSetting( "APP_DOMAIN", "localhost" ),

        // Allowed origins (must include https:// + rpId)
        origins : [
            "https://" & getSystemSetting( "APP_DOMAIN", "localhost" )
        ],

        // Timeout for ceremony challenges (milliseconds)
        timeout : 60000
    }
}

ICredentialRepository Interface

// models/PasskeyCredentialRepository.bx
class implements="cbsecurity.modules.cbsecurity-passkeys.interfaces.ICredentialRepository" {

    property name="qb" inject="QueryBuilder@qb";

    /**
     * Find all credentials for a given user handle (user ID)
     */
    array function findByUserHandle( userHandle ) {
        return qb.from( "passkey_credentials" )
            .where( "userId", userHandle )
            .get()
    }

    /**
     * Find a single credential by its credential ID (base64url)
     */
    struct function findById( credentialId ) {
        return qb.from( "passkey_credentials" )
            .where( "credentialId", credentialId )
            .first()
    }

    /**
     * Persist a newly registered credential
     */
    void function save( credential ) {
        qb.table( "passkey_credentials" ).insert( {
            id                   : createUUID(),
            userId               : credential.userHandle,
            credentialId         : credential.credentialId,
            publicKey            : credential.publicKey,
            signCount            : credential.signCount,
            transports           : serializeJSON( credential.transports ?: [] ),
            aaguid               : credential.aaguid ?: "",
            createdAt            : now()
        } )
    }

    /**
     * Update the sign counter after successful authentication
     */
    void function updateSignCount( credentialId, signCount ) {
        qb.table( "passkey_credentials" )
            .where( "credentialId", credentialId )
            .update( { signCount: signCount, lastUsedAt: now() } )
    }

    /**
     * Delete a credential
     */
    void function delete( credentialId ) {
        qb.table( "passkey_credentials" )
            .where( "credentialId", credentialId )
            .delete()
    }
}

Database Migration for Credentials

function up( schema ) {
    schema.create( "passkey_credentials", ( table ) => {
        table.uuid( "id" ).primary()
        table.string( "userId",       36 )
        table.string( "credentialId", 1000 )
        table.longText( "publicKey" )
        table.bigInteger( "signCount" ).default( 0 )
        table.string( "transports", 500 ).nullable()
        table.string( "aaguid", 36 ).nullable()
        table.dateTime( "createdAt" )
        table.dateTime( "lastUsedAt" ).nullable()
        table.index( "userId" )
        table.unique( "credentialId" )
    } )
}

Registration Ceremony

// handlers/Passkeys.bx
@secured
class {

    property name="passkeyService" inject="PasskeyService@cbsecurity-passkeys";
    property name="security"       inject="SecurityService@cbsecurity";

    // Step 1: Generate registration options and store challenge in session
    function registrationOptions( event, rc, prc ) {
        var user    = security.getCurrentUser()
        var options = passkeyService.generateRegistrationOptions( {
            userId      : user.getId(),
            username    : user.getEmail(),
            displayName : user.getFullName()
        } )

        // Store challenge server-side for verification
        sessionStorage.set( "passkeyChallenge", options.challenge )

        event.renderData( type = "json", data = options )
    }

    // Step 2: Verify and persist the credential
    function registerCredential( event, rc, prc ) {
        try {
            var challenge = sessionStorage.get( "passkeyChallenge" )
            sessionStorage.delete( "passkeyChallenge" )

            passkeyService.verifyRegistration(
                credential : rc.credential,
                challenge  : challenge,
                userHandle : security.getCurrentUser().getId()
            )

            event.renderData( type = "json", data = { success: true } )

        } catch ( any e ) {
            event.renderData(
                type       = "json",
                statusCode = 400,
                data       = { error: "Registration failed: #e.message#" }
            )
        }
    }
}

Authentication Ceremony

// handlers/Auth.bx
@unsecured
class {

    property name="passkeyService" inject="PasskeyService@cbsecurity-passkeys";
    property name="jwtService"     inject="JwtService@cbsecurity";

    // Step 1: Generate authentication options
    function authOptions( event, rc, prc ) {
        var options = passkeyService.generateAuthenticationOptions()
        sessionStorage.set( "passkeyChallenge", options.challenge )
        event.renderData( type = "json", data = options )
    }

    // Step 2: Verify assertion and issue token
    function authenticate( event, rc, prc ) {
        try {
            var challenge = sessionStorage.get( "passkeyChallenge" )
            sessionStorage.delete( "passkeyChallenge" )

            var result = passkeyService.verifyAuthentication(
                credential : rc.credential,
                challenge  : challenge
            )

            // Issue JWT for the verified user
            var token = jwtService.fromUser( userService.get( result.userHandle ) )

            event.renderData( type = "json", data = { token: token } )

        } catch ( any e ) {
            event.renderData(
                type       = "json",
                statusCode = 401,
                data       = { error: "Authentication failed" }
            )
        }
    }
}

Best Practices

  • Always use HTTPS — WebAuthn requires a secure context; passkeys will not work over HTTP
  • Set rpId to the effective domain only — do not include protocol or port; must match browser origin
  • Delete the challenge from session immediately after use — prevents replay attacks
  • Validate signCount on each use — a decreasing count indicates a cloned authenticator
  • Never log credential material — specifically publicKey and raw assertion bytes
  • Offer passkeys as an additional method alongside passwords (progressive rollout)
  • Verify all ceremony options — never skip verifying origin, rpId, and challenge on the server
  • Return generic error messages on failure — avoid leaking which step failed

Documentation

cbsecurity-passkeys — skill by ColdBox | Shared Context