In parts one and two, our passkey ceremonies ran inside the same application that registered and authenticated the user. That works well until we have more than one application. Perhaps we have an administrative application at:
https://admin.example.comand a public application at:
https://www.example.comWe could give each application its own passkeys. The user could register one passkey for the administrative application and another for the public application. Then they could forget which one they were using, blame us and be entirely correct.
A cleaner approach is to perform every passkey ceremony at one authentication origin:
https://auth.example.comThe other applications redirect there when they need authentication. The authentication origin verifies the passkey and sends the browser back with a short-lived, single-use authorization code. It doesn't share its session cookie. It doesn't place the user’s identity in the return address. It doesn't ask every application to become a part-time identity provider. Conceptually, the flow looks like this:
Relying application
|
| Create transaction
v
https://auth.example.com/passkey/start.cfm
|
| Run passkey ceremony
v
https://auth.example.com/passkey/callback.cfm
|
| Return one-time authorization code
v
Relying application callback
|
| Redeem code
v
Application creates its own sessionThe authentication origin proves identity. The relying application decides what that identity is allowed to do. Keeping those jobs separate prevents a central authentication service from becoming a universal permission dispenser, which is the sort of convenience attackers tend to appreciate more than users.
Why Redirect Everything to One Origin?
A passkey is scoped to a relying party identifier. The browser and authenticator won't allow a credential registered for one relying party to be exercised by an unrelated one. The Web Authentication specification allows a relying party identifier to equal the ceremony’s effective domain or a registrable parent domain. That means we could register credentials using example.com and potentially use them from several subdomains.
I don't do that here. Using a parent-domain relying party identifier expands the collection of origins that can ask the browser to use the credential. Every trusted subdomain becomes part of the security boundary, including the forgotten marketing site somebody launched six years ago and nobody has been brave enough to inspect since.
Instead, every ceremony runs at:
https://auth.example.comand every passkey uses:
rpId: "auth.example.com"rpId is ColdFusion's required property name. We can spell out “relying party identifier” everywhere else, but the structure passed to ColdFusion must use the property name ColdFusion expects. I hate abbreviations. They make me think. I don't like to have to think about what a variable means.
The administrative and public applications never call the browser credential interface themselves. They redirect the user to the authentication origin, which owns the passkey ceremony.
Code takeaway: Choose one fixed authentication hostname and perform every registration and authentication ceremony there.
Existing Passkeys Won't Move
A passkey registered for admin.example.com doesn't become a passkey for auth.example.com because we changed a configuration value. The authenticator remembers the relying party identifier used during registration. Changing the value in our database won't change the credential held by the user’s device. Users must register a new passkey at the central authentication origin.
A safe migration looks like this:
- The user signs in using an existing trusted method.
- The application starts a central passkey registration transaction.
- The browser moves to
auth.example.com. - The user registers a new passkey there.
- The browser returns to the original application.
- The old application-specific passkey remains available during migration.
- The old credential is removed after the transition period.
Don't silently delete the old credential the moment the new one appears. A successful registration on one device doesn't prove that the user has synchronized it everywhere they need it. Authentication migrations are a poor place to discover optimism.
Code takeaway: Treat the new authentication origin as a new relying party. Existing passkeys must be registered again.
What the Browser May Carry
The browser has to carry something between the relying application and the authentication origin. It shouldn't carry this:
user_id=019cc835-28c5-7eec-a8ab-9c4fe4ac4934
role=administrator
callback=https://whatever-the-browser-supplied.exampleInstead, it carries opaque random values:
transaction=JkM3pT...
code=V8yN4q...The database stores hashes of those values. A transaction records the server-controlled decisions behind the request:
- Which application started it
- Which fixed callback belongs to that application
- Whether this is authentication or registration
- Which user may receive a new passkey during registration
- When the transaction expires
- A hash of state held by the initiating application
After the passkey ceremony succeeds, the authentication origin creates a second opaque value: the authorization code. That code is:
- Short-lived
- Single-use
- Bound to one transaction
- Bound to one relying application
- Bound to the browser session that started the transaction
It contains no user information. It is only a random pointer to server-side state.
Code takeaway: Pass opaque random values through the browser. Keep identity, intent and callback decisions on the server.
Creating the Transaction Table
This example uses PostgreSQL. Adapt the timestamp and generated-key syntax if your application uses another database.
CREATE TABLE central_passkey_transaction (
id varchar(36) PRIMARY KEY,
start_token_hash varchar(64) NOT NULL UNIQUE,
state_hash varchar(64) NOT NULL,
relying_application varchar(50) NOT NULL,
callback_address varchar(500) NOT NULL,
intent varchar(20) NOT NULL,
linking_user_id varchar(100),
authenticated_user_id varchar(100),
authorization_code_hash varchar(64) UNIQUE,
created_at timestamp with time zone NOT NULL,
expires_at timestamp with time zone NOT NULL,
started_at timestamp with time zone,
completed_at timestamp with time zone,
redeemed_at timestamp with time zone
);
CREATE INDEX central_passkey_transaction_expires
ON central_passkey_transaction (expires_at);The table stores hashes of the start token, state value and authorization code. If somebody obtains a database copy, those values can't be placed directly into a browser and redeemed. The relying_application value is a short server-defined name such as:
administration
publicIt is not a hostname supplied by the browser. The callback is also selected from server configuration. We never accept an arbitrary callback address during transaction creation.
Code takeaway: Store token hashes, not raw bearer values, and bind every transaction to a server-recognized application and callback.
Building the Transaction Service
Create models/central_authentication_transaction_service.cfc:
component output="false" {
public centralAuthenticationTransactionService function init(
required string datasource,
required string authenticationBaseAddress,
required struct callbackAddresses
) {
variables.datasource = arguments.datasource;
variables.authenticationBaseAddress = reReplace( arguments.authenticationBaseAddress, "/+$", "" );
variables.callbackAddresses = duplicate( arguments.callbackAddresses );
variables.transactionLifetimeSeconds = 600;
variables.authorizationCodeLifetimeSeconds = 180;
return this;
}
public struct function createTransaction(
required string relyingApplication,
required string intent,
string linkingUserId = ""
) {
var applicationName = lCase( trim( arguments.relyingApplication ) );
var normalizedIntent = lCase( trim( arguments.intent ) );
if ( !structKeyExists( variables.callbackAddresses, applicationName ) ) {
throw(
type = "CentralAuthentication.UnknownApplication",
message = "The relying application is not configured."
);
}
if ( !listFindNoCase( "authentication,registration", normalizedIntent ) ) {
throw(
type = "CentralAuthentication.InvalidIntent",
message = "The authentication intent is not supported."
);
}
if (
normalizedIntent == "registration"
&& !len( trim( arguments.linkingUserId ) )
) {
throw(
type = "CentralAuthentication.MissingUser",
message = "Registration requires an authenticated user."
);
}
var transactionId = createUUID();
var startToken = generateOpaqueToken();
var state = generateOpaqueToken();
var expiresAt = dateAdd( "s", variables.transactionLifetimeSeconds, now() );
queryExecute(
sql = "
INSERT INTO central_passkey_transaction (
id,
start_token_hash,
state_hash,
relying_application,
callback_address,
intent,
linking_user_id,
created_at,
expires_at
) VALUES (
:id,
:startTokenHash,
:stateHash,
:relyingApplication,
:callbackAddress,
:intent,
:linkingUserId,
:createdAt,
:expiresAt
)
",
params = {
id: { value: transactionId, cfsqltype: "varchar" },
startTokenHash: { value: hash( startToken, "SHA-256" ), cfsqltype: "varchar" },
stateHash: { value: hash( state, "SHA-256" ), cfsqltype: "varchar" },
relyingApplication: { value: applicationName, cfsqltype: "varchar" },
callbackAddress: { value: variables.callbackAddresses[ applicationName ], cfsqltype: "varchar" },
intent: { value: normalizedIntent, cfsqltype: "varchar" },
linkingUserId: { value: trim( arguments.linkingUserId ), null: !len( trim( arguments.linkingUserId ) ), cfsqltype: "varchar" },
createdAt: { value: now(), cfsqltype: "timestamp" },
expiresAt: { value: expiresAt, cfsqltype: "timestamp" }
},
options = { datasource: variables.datasource }
);
return {
state: state,
startAddress: variables.authenticationBaseAddress & "/passkey/start.cfm?transaction=" & encodeForURL( startToken )
};
}
public struct function claimStartToken(
required string startToken
) {
var rows = queryExecute(
sql = "
UPDATE
central_passkey_transaction
SET
started_at = :startedAt
WHERE
start_token_hash = :startTokenHash
AND started_at IS NULL
AND completed_at IS NULL
AND redeemed_at IS NULL
AND expires_at > :currentTime
RETURNING
id,
relying_application,
callback_address,
intent,
linking_user_id,
expires_at
",
params = {
startedAt: { value: now(), cfsqltype: "timestamp" },
startTokenHash: { value: hash( trim( arguments.startToken ), "SHA-256" ), cfsqltype: "varchar" },
currentTime: { value: now(), cfsqltype: "timestamp" }
},
options = { datasource: variables.datasource, returnType: "array" }
);
return arrayLen( rows ) ? rows[ 1 ] : {};
}
public struct function completeTransaction(
required string transactionId,
required string authenticatedUserId
) {
var authorizationCode = generateOpaqueToken();
var authorizationCodeExpiresAt = dateAdd( "s", variables.authorizationCodeLifetimeSeconds, now() );
var rows = queryExecute(
sql = "
UPDATE
central_passkey_transaction
SET
authenticated_user_id = :authenticatedUserId,
authorization_code_hash = :authorizationCodeHash,
completed_at = :completedAt,
expires_at = :authorizationCodeExpiresAt
WHERE
id = :transactionId
AND started_at IS NOT NULL
AND completed_at IS NULL
AND redeemed_at IS NULL
AND expires_at > :currentTime
RETURNING
callback_address
",
params = {
authenticatedUserId: { value: trim( arguments.authenticatedUserId ), cfsqltype: "varchar" },
authorizationCodeHash: { value: hash( authorizationCode, "SHA-256" ), cfsqltype: "varchar" },
completedAt: { value: now(), cfsqltype: "timestamp" },
authorizationCodeExpiresAt: { value: authorizationCodeExpiresAt, cfsqltype: "timestamp" },
transactionId: { value: arguments.transactionId, cfsqltype: "varchar" },
currentTime: { value: now(), cfsqltype: "timestamp" }
},
options = { datasource: variables.datasource, returnType: "array" }
);
if ( !arrayLen( rows ) ) { return {}; }
return {
code: authorizationCode,
callbackAddress: rows[ 1 ].callback_address
};
}
public struct function redeemAuthorizationCode(
required string authorizationCode,
required string relyingApplication,
required string state
) {
var rows = queryExecute(
sql = "
UPDATE
central_passkey_transaction
SET
redeemed_at = :redeemedAt
WHERE
authorization_code_hash = :authorizationCodeHash
AND state_hash = :stateHash
AND relying_application = :relyingApplication
AND completed_at IS NOT NULL
AND redeemed_at IS NULL
AND expires_at > :currentTime
RETURNING
authenticated_user_id,
intent,
linking_user_id
",
params = {
redeemedAt: { value: now(), cfsqltype: "timestamp" },
authorizationCodeHash: { value: hash( trim( arguments.authorizationCode ), "SHA-256" ), cfsqltype: "varchar" },
stateHash: { value: hash( trim( arguments.state ), "SHA-256" ), cfsqltype: "varchar" },
relyingApplication: { value: lCase( trim( arguments.relyingApplication ) ), cfsqltype: "varchar" },
currentTime: { value: now(), cfsqltype: "timestamp" }
},
options = { datasource: variables.datasource, returnType: "array" }
);
return arrayLen( rows ) ? rows[ 1 ] : {};
}
private string function generateOpaqueToken() {
var token = generateSecretKey( "AES", 256 );
token = replace( token, "+", "-", "all" );
token = replace( token, "/", "_", "all" );
token = replace( token, "=", "", "all" );
return token;
}
}The raw start token and state value are returned only to the application that creates the transaction. The database receives their hashes. claimStartToken() updates and returns the transaction in one operation. A second request using the same start token gets nothing. redeemAuthorizationCode() also updates and returns in one operation. The first valid redemption wins. Replaying the code fails because redeemed_at is no longer empty.
This example uses PostgreSQL’s RETURNING clause. If your database doesn't support it, perform the selection and update inside a database transaction with row locking. Don't split a single-use operation into an unprotected selection followed by an update. Two requests will eventually arrive together, because computers regard “unlikely” as a scheduling suggestion.
Code takeaway: Claim start tokens and redeem authorization codes atomically. Checking first and updating later creates a replay race.
Configuring the Known Applications
Both the relying applications and the authentication origin need the same callback configuration. In Application.cfc's onApplicationStart() method:
<cfscript>
application.centralAuthenticationTransactions = new models.CentralAuthenticationTransactionService(
datasource = "passkey_demo",
authenticationBaseAddress = "https://auth.example.com",
callbackAddresses = {
administration: "https://app.example.com/authentication/passkey-return.cfm",
members: "https://members.example.com/authentication/passkey-return.cfm"
}
);
</cfscript>These callback addresses come from configuration checked into or deployed with the application. They don't come from a query string, submitted form or incoming host header. Adding another relying application requires adding another server-controlled entry. That inconvenience is intentional. An open callback turns the authentication origin into a credential-delivery service for anybody capable of constructing an address. We already have enough businesses accidentally offering that feature.
Code takeaway: Map relying application names to fixed callback addresses on the server.
Starting Authentication from a Relying Application
The administrative application identifies itself using a fixed value:
<cfscript>
application.relyingApplication = "administration";
</cfscript>Create authentication/start-passkey.cfm in that application:
<cfscript>
transaction = application.centralAuthenticationTransactions.createTransaction(
relyingApplication = application.relyingApplication,
intent = "authentication"
);
session.pendingCentralPasskey = {
state: transaction.state,
intent: "authentication",
createdAt: now(),
returnPath: "/account/index.cfm"
};
location(
url = transaction.startAddress,
addToken = false
);
</cfscript>The relying application stores the raw state value in its own session. The authentication origin never needs that raw value. When the browser eventually returns, the relying application presents the state while redeeming the authorization code. The service compares its hash with the transaction. This prevents forced login.
Without that binding, an attacker could authenticate into their own account, obtain a valid return address and convince another browser to visit it. The victim would then be signed into the attacker’s account and might upload private information while believing it belonged to them. Not every authentication attack tries to enter the victim’s account. Sometimes the attacker wants the victim to enter theirs.
Code takeaway: Bind the transaction to the initiating application session using a random state value.
Starting Registration from a Relying Application
Registration begins from an existing authenticated session. It must also be protected against cross-site request forgery.
Create account/start-passkey-registration.cfm:
<cfscript>
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, "central-passkey-registration" ) ) {
location( url = "/account/security.cfm?passkey=invalid_request", addToken = false );
}
transaction = application.centralAuthenticationTransactions.createTransaction(
relyingApplication = application.relyingApplication,
intent = "registration",
linkingUserId = toString( session.user.id )
);
session.pendingCentralPasskey = {
state: transaction.state,
intent: "registration",
userId: toString( session.user.id ),
createdAt: now(),
returnPath: "/account/security.cfm"
};
location( url = transaction.startAddress, addToken = false );
</cfscript>The transaction records the user who is allowed to receive the new credential. The authentication origin will compare that user with the result returned by ColdFusion. The relying application will compare them again when the authorization code comes back.
Repeated checks are appropriate here. Authentication code should be slightly paranoid. It has seen things.
Code takeaway: A registration transaction must be tied to the already-authenticated user who started it.
Configuring the Authentication Origin
The authentication origin gets its own ColdFusion application and session:
component {
this.name = "centralPasskeyAuthentication";
this.sessionManagement = true;
this.sessionTimeout = createTimespan( 0, 0, 15, 0 );
this.setClientCookies = true;
public boolean function onApplicationStart() {
application.authenticationHost = "auth.example.com";
application.passkeys = new models.nativePasskeyService(
rpName = "Example Application",
rpId = "auth.example.com",
servicePath = "/__cf_passkey/DatabasePasskey.cfc",
callbackPath = "/passkey/callback.cfm"
);
application.centralAuthenticationTransactions =
new models.CentralAuthenticationTransactionService(
datasource = "passkey_demo",
authenticationBaseAddress = "https://auth.example.com",
callbackAddresses = {
administration: "https://app.example.com/authentication/passkey-return.cfm",
members: "https://members.example.com/authentication/passkey-return.cfm"
}
);
application.users = new models.UserService( datasource = "passkey_demo" );
return true;
}
public boolean function onRequestStart() {
if ( compareNoCase( cgi.server_name, application.authenticationHost ) != 0 ) {
cfheader( statusCode = 404, statusText = "Not Found" );
abort;
}
return true;
}
}The web server should route only auth.example.com to this application. The onRequestStart() check is defence in depth, not a substitute for correct web-server configuration. The passkey service path must resolve inside this application, just as it did in part one. ColdFusion’s session-bound ceremony protection still requires databasePasskey.cfc to execute within the application and session that called passkeyRegister() or passkeyAuthenticate(). We moved the ceremony. We didn't repeal any of its requirements.
Code takeaway: Give the authentication origin its own application session, fixed hostname, local passkey service path and fixed relying party identifier.
Running the Ceremony at the Authentication Origin
Create passkey/start.cfm at auth.example.com:
<cfscript>
param name = "url.transaction" default = "";
transaction = application.centralAuthenticationTransactions.claimStartToken( url.transaction );
if ( structIsEmpty( transaction ) ) {
location( url = "/passkey/failed.cfm?reason=invalid_transaction", addToken = false );
}
session.centralPasskeyTransaction = {
id: transaction.id,
intent: transaction.intent,
linkingUserId: transaction.linking_user_id ?: ""
};
try {
if ( transaction.intent == "registration" ) {
user = application.users.findActiveById( transaction.linking_user_id );
if ( structIsEmpty( user ) ) {
location( url = "/passkey/failed.cfm?reason=user_unavailable", addToken = false );
}
PasskeyRegister( application.passkeys.buildRegistrationUser( user ), application.passkeys.buildConfig() );
request.passkeyAction = "registration";
}
else {
PasskeyAuthenticate( application.passkeys.buildAuthenticationUser(), application.passkeys.buildConfig() );
request.passkeyAction = "authentication";
}
}
catch ( any error ) {
structDelete( session, "centralPasskeyTransaction" );
writeLog(
type = "error",
file = "authentication",
text = "CENTRAL_PASSKEY_START_FAILED" & " type=#error.type ?: ''#" & " message=#error.message ?: ''#"
);
location( url = "/passkey/failed.cfm?reason=start_failed", addToken = false );
}
include "../includes/passkey-ceremony.cfm";
</cfscript>The ceremony page is the shared registration and authentication page from part two. It calls either:
CFPasskey.startRegistration()or:
CFPasskey.startAuthentication()The relying applications never call those functions now. Every browser ceremony begins and ends at auth.example.com. The central session retains only the transaction identifier, intent and linking user. The database remains the authority for expiry, callback selection and whether the transaction can still be completed.
Code takeaway: Resolve the transaction before starting the ceremony and store only the minimum callback context in the authentication-origin session.
Completing the Central Ceremony
Create passkey/callback.cfm at the authentication origin:
<cfscript>
param name = "url.passkey_token" default = "";
transaction = session.centralPasskeyTransaction ?: {};
structDelete( session, "centralPasskeyTransaction" );
if ( structIsEmpty( transaction ) ) {
location( url = "/passkey/failed.cfm?reason=invalid_session", addToken = false );
}
result = application.passkeys.interpretResult( url.passkey_token );
if ( !result.success || result.action != transaction.intent ) {
location url = "/passkey/failed.cfm?reason=ceremony_failed" addToken = fals );
}
if ( transaction.intent == "registration" && compareNoCase( result.userId, transaction.linkingUserId ) != 0 ) {
writeLog(
type = "error",
file = "authentication",
text = "CENTRAL_PASSKEY_REGISTRATION_USER_MISMATCH"
);
location( url = "/passkey/failed.cfm?reason=user_mismatch", addToken = false );
}
user = application.users.findActiveById( result.userId );
if ( structIsEmpty( user ) ) {
location( url = "/passkey/failed.cfm?reason=user_unavailable", addToken = false );
}
handoff = application.centralAuthenticationTransactions.completeTransaction( transactionId = transaction.id, authenticatedUserId = result.userId );
if ( structIsEmpty( handoff ) ) {
location( url = "/passkey/failed.cfm?reason=transaction_expired", addToken = false );
}
separator = find( "?", handoff.callbackAddress ) ? "&" : "?";
location( url = handoff.callbackAddress & separator & "code=" & encodeForURL( handoff.code ), addToken = false );
</cfscript>The callback validates three different things:
- ColdFusion says the ceremony succeeded.
- The returned action matches the transaction intent.
- Registration returned the same user the relying application originally supplied.
It also reloads the user before issuing the authorization code. A valid passkey shouldn't produce a handoff for a user who has been disabled since the transaction began. The browser receives only the opaque authorization code. It doesn't receive the user identifier, roles, email address or a signed collection of claims that will live forever in browser history because somebody set the expiration year to 2099 during testing.
Code takeaway: Issue an authorization code only after validating the ceremony action, user binding and current account state.
Redeeming the Code
The browser now returns to the relying application. Create authentication/passkey-return.cfm there:
<cfscript>
param name = "url.code" default = "";
pending = session.pendingCentralPasskey ?: {};
structDelete( session, "pendingCentralPasskey" );
if (
structIsEmpty( pending )
|| !isDate( pending.createdAt )
|| dateDiff( "s", pending.createdAt, now() ) > 600
) {
location( url = "/signin.cfm?passkey=invalid_state", addToken = false );
}
result = application.centralAuthenticationTransactions.redeemAuthorizationCode(
authorizationCode = url.code,
relyingApplication = application.relyingApplication,
state = pending.state
);
if ( structIsEmpty( result ) ) {
location( url = "/signin.cfm?passkey=invalid_code", addToken = false );
}
if ( result.intent != pending.intent ) {
location( url = "/signin.cfm?passkey=intent_mismatch", addToken = false );
}
if ( result.intent == "registration" ) {
if (
!structKeyExists( session, "signedIn" )
|| !session.signedIn
|| toString( session.user.id ) != toString( result.authenticated_user_id )
|| toString( pending.userId ) != toString( result.authenticated_user_id )
) {
location( url = "/signin.cfm?passkey=user_mismatch", addToken = false );
}
location( url = pending.returnPath & "?passkey=registered", addToken = false );
}
user = application.users.findActiveById( result.authenticated_user_id );
if ( structIsEmpty( user ) ) {
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 = "CENTRAL_PASSKEY_AUTHENTICATION_COMPLETED" & " user_id=#user.id#"
);
location( url = pending.returnPath, addToken = false );
</cfscript>The relying application redeems the code using three values:
The authorization code returned by the authentication origin
The application’s fixed identity
The state retained in the initiating sessionAll three must match the same live transaction. The database update consumes the code while returning the authenticated user. A refresh, browser-history replay or copied callback address can't establish another session. After redemption, the relying application loads the user and applies its own rules. The authentication origin doesn't create this session. It doesn't decide which roles belong in it. It doesn't decide whether the user has access to this particular application.
Identity crossed the boundary. Authorization didn't.
Code takeaway: Redeem the code once, confirm the application and state bindings, then create a fresh local session using current application data.
Don't Share Session Cookies
It may be tempting to configure one session cookie for every subdomain and let auth.example.com place the authenticated user directly into it.
Don't. Just... don't.
A shared parent-domain cookie means every application receiving that cookie becomes part of the session-security boundary. A vulnerability in one subdomain can affect all of them. It also couples unrelated ColdFusion applications to:
- The same session name
- The same cookie settings
- The same serialization assumptions
- The same session lifetime
- The same deployment topology
- The same future mistakes
The authorization-code handoff is more work, but each application keeps its own session. The authentication origin proves identity once. Each relying application consumes that proof and creates a session appropriate to itself. Shared cookies feel simple because they move complexity somewhere harder to see.
Code takeaway: Keep authentication-origin and relying-application sessions separate. Transfer identity through a short-lived code, not a parent-domain cookie.
Testing the Boundaries
The useful tests aren't limited to whether the happy path reaches the account page. Test these transaction rules directly:
it(
"does not allow another application to redeem the code",
function() {
var result = service.redeemAuthorizationCode(
authorizationCode = issuedCode,
relyingApplication = "members",
state = administrationState
);
expect( result ).toBeEmpty();
}
);
it(
"does not redeem a code with the wrong state",
function() {
var result = service.redeemAuthorizationCode(
authorizationCode = issuedCode,
relyingApplication = "administration",
state = "not-the-original-state"
);
expect( result ).toBeEmpty();
}
);
it(
"does not redeem the same code twice",
function() {
var first = service.redeemAuthorizationCode(
authorizationCode = issuedCode,
relyingApplication = "administration",
state = administrationState
);
var second = service.redeemAuthorizationCode(
authorizationCode = issuedCode,
relyingApplication = "administration",
state = administrationState
);
expect( first ).notToBeEmpty();
expect( second ).toBeEmpty();
}
);Also test the complete browser flow:
- Start authentication from each relying application.
- Confirm that every ceremony occurs at
auth.example.com. - Confirm that the browser offers the same central passkeys.
- Confirm that each application creates its own session after redemption.
- Confirm that signing out of one application doesn't silently destroy the other application’s session.
- Change the returned state and confirm redemption fails.
- Replay a successful callback and confirm redemption fails.
- Attempt to use an authorization code in another application.
- Allow the transaction to expire before completing the ceremony.
- Start registration as one user and attempt to return a result for another.
- Confirm that an old
admin.example.compasskey isn't offered atauth.example.com. - Register a replacement passkey at the central origin and confirm it works from both initiating applications.
Security boundaries deserve hostile tests. If our tests always do exactly what the user interface intended, we're testing politeness again. The internet remains uninterested in participating.
What We Built
We now have a central passkey authentication origin:
- Every passkey ceremony runs at one fixed hostname.
- Every new passkey uses the same relying party identifier.
- Relying applications create server-side transactions before redirecting.
- Callback addresses come from a server-controlled allowlist.
- Registration remains bound to an existing authenticated user.
- Authentication results become opaque, single-use authorization codes.
- Codes are bound to the transaction, relying application and initiating session.
- Each application reloads current user and authorization data.
- Each application creates and owns its own session.
- Existing application-specific passkeys have a defined migration path.
We haven't shared session cookies. We haven't placed user identities in browser addresses. We haven't allowed the browser to choose where authentication results are delivered. These are low bars, but authentication history has demonstrated a surprising willingness to tunnel beneath them.
In part four, we'll make this survive reverse proxies and multiple ColdFusion nodes. That means forwarded protocol and host information, origin validation, application-local passkey service routing, shared challenge storage, load-balancer stickiness and deployment checks that fail before users discover the problem for us. At the moment, everything works because every request reaches the same ColdFusion instance and the application sees the same origin the browser sees.
We're going to absolutely ruin that simplicity next.