In Part 1, we registered a passkey. In Part 2, we used it to authenticate a user. In Part 3, we moved the ceremony to a central authentication origin and returned a short-lived, single-use authorization code to the requesting application.
That gave us a useful architecture:
app.example.com
→ auth.example.com
→ native passkey ceremony
→ app.example.com callbackß
→ application sessionNow we need to run it behind an AWS Application Load Balancer, with more than one ColdFusion instance. This is where a working passkey implementation can become a surprisingly effective infrastructure diagnostic tool. The browser knows which origin it is visiting. ColdFusion has its own view of that request. Multiple nodes have their own ideas about which state they possess.
All of them need to agree.
The Public Origin Must Survive the Proxy Chain
Consider this deployment:
Browser
HTTPS :443
↓
AWS Application Load Balancer
HTTP :80
↓
Apache
Internal connector
↓
ColdFusionTLS terminates at the ALB. The internal connection uses HTTP. That arrangement can work, but ColdFusion must still understand that the original browser request arrived over HTTPS. For our authentication service, the public origin is:
https://auth.example.comThe origin includes the scheme, hostname, and effective port. These are different origins:
https://auth.example.com
http://auth.example.com
https://auth.example.com:8081The relying-party ID remains:
auth.example.comIt does not include a scheme, port, or path. Changing the RP ID to accommodate an internal listener port would solve the wrong problem. In the deployments behind this article, we encountered two distinct failures:
Browser origin | ColdFusion’s effective origin | Problem |
|---|---|---|
|
| External HTTPS was lost |
|
| Internal listener port leaked into origin validation |
Both failures happened before we could complete a useful passkey ceremony.
Forwarded Headers Are Only the First Step
An ALB supplies X-Forwarded-Proto and X-Forwarded-Port so downstream services can identify the original connection’s protocol and port. AWS documents these headers here.
However, receiving this:
X-Forwarded-Proto: https
X-Forwarded-Port: 443does not prove that the servlet request, or the CGI values ColdFusion exposes, reflects those values. In our original failure, the headers were present while ColdFusion still reported:
cgi.https = off
cgi.server_port = 80The configuration looked plausible. The resulting request was wrong.
Inspect the request through the actual public authentication URL. Record the scheme, hostname, and port that the engine uses, then compare them with the configured public origin. Keep that diagnostic protected and narrowly scoped. There is no need to publish an unrestricted CGI dump to discover that the port is wrong. Also establish the proxy trust boundary. Backend listeners should be reachable only through the intended infrastructure, and forwarded metadata should be accepted only from trusted proxies. An arbitrary request header must not become authority for your authentication origin.
Fix the Runtime You Actually Have
Our original deployment used ColdFusion’s bundled Tomcat through an Apache AJP connector.
For a connector serving exclusively HTTPS-originated traffic from the trusted proxy chain, the relevant attributes were:
scheme="https"
secure="true"
proxyPort="443"These belong on the existing connector, with its existing binding and security settings preserved. Tomcat documents their effects on the request scheme, secure status, and server port. Tomcat AJP connector reference.
A deployment handling mixed external protocols needs a configuration that derives those values from trusted proxy information. Tomcat’s RemoteIpValve provides that mechanism, with explicit proxy trust configuration. Tomcat valve reference.
The important test comes after configuration: Does a request through https://auth.example.com now reach ColdFusion with the correct effective origin? Run that check again after engine updates. A previously corrected connector file can be replaced during an upgrade.
The CommandBox Port Problem
Later, on an ARM deployment using Adobe ColdFusion 2025.0.11 under CommandBox and Undertow, we encountered a different problem. The scheme and hostname were correct. The server port remained the internal HTTP listener port:
cgi.https = on
cgi.server_name = auth.example.com
cgi.server_port = 8081ColdFusion’s native passkey processing consequently expected:
https://auth.example.com:8081We tried forwarded-port settings and host-header changes. In that runtime, those changes did not correct the server-port value used by the application’s origin check. The workaround that changed the observed value was to move the engine’s loopback HTTP listener to port 443:
{
"web": {
"http": {
"host": "127.0.0.1",
"port": 443
}
}
}Apache then proxied to:
http://127.0.0.1:443/Yes, that is HTTP on port 443. Fucked up, amirite?
The ALB still terminated public TLS. The internal listener’s port number did not enable TLS, and the proxy URL had to retain http://. Trusted proxy processing still supplied the correct external scheme. This was a workaround for the specific runtime behaviour we measured. It is not a general requirement that ColdFusion applications listen on port 443. Prefer a supported proxy configuration that produces the correct request values when your runtime supports it.
The workaround also brought two ordinary Linux details into the conversation:
- An unprivileged service needed permission to bind a low port. We granted the service
CAP_NET_BIND_SERVICErather than running it as root. - Apache already had a port-443 listener from its SSL configuration. We had to reconcile port ownership for this ALB-terminated topology before the engine could bind successfully.
A startup message was insufficient proof. We encountered a “server is up” message alongside a bind failure. Verify the listening process, make an HTTP request, and inspect the resulting ColdFusion request values. The log’s optimism is not a health check.
Keep ColdFusion’s Service Inside the Authentication Application
Part 1 introduced an application-local route to ColdFusion’s native passkey service:
/__cf_passkey/DatabasePasskey.cfcThat remains important behind a load balancer. In the CommandBox deployment, the /CFIDE mapping could reach the engine’s webroot outside the initiating application. The browser reached the service, but the service did not participate in the expected application and session context.
The result was CSRF_INVALID. Successful routing and correct application context are separate requirements. The authentication application’s local alias must resolve to the native passkey files belonging to the engine running that application. Each target needs the same arrangement.
Keep the broader /CFIDE surface blocked, expose only the required service endpoint, and verify the real browser POST through that route. Avoid copying ColdFusion’s CFC into your application: a copied implementation can quietly diverge from the installed engine after an update. Disabling CSRF validation would remove the protection that exposed the routing problem. Fix the routing.
Provision Every Engine
The application repository does not contain every part of a native passkey deployment. Each ColdFusion engine needs its passkey configuration: the credential datasource, challenge-store selection, and challenge lifetime. In our deployment workflow, these settings lived in engine security configuration and were not automatically carried across by the CFConfig export/import process we were using. Deploying the same application to two nodes therefore did not prove that both engines were configured.
Provision those settings through a protected administrative process, using the Admin API shown earlier in the series. Verify the effective configuration on every target and confirm that it survives a restart. ColdFusion supports multiple challenge-store choices through setPasskeyConfig(). Selecting a store and provisioning the infrastructure behind that store are separate steps. Adobe’s native passkey documentation.
A shared credential database is necessary for multiple engines to recognize the same registered credentials. It does not automatically share the temporary state of an authentication ceremony. That distinction matters next.
“Our Sessions Are in Redis” Is an Incomplete Answer
A native passkey login involves several kinds of state:
State | Purpose | Deployment question |
|---|---|---|
Registered credentials | Associate a user with public-key credentials | Do all engines use the intended shared database? |
Native challenges | Bind responses to a particular ceremony | Can the validating node retrieve the challenge? |
Native CSRF state | Validate requests to ColdFusion’s service | Does validation survive a node change? |
Native result-token state | Let the callback obtain the completed result | Can the callback resolve the token on its target? |
Authentication application session | Preserve the pending central transaction | Does it survive routing changes? |
Relying application session | Preserve the state from Part 3 | Is it available when the user returns? |
Central transaction and code records | Bind and redeem the handoff | Are they shared and consumed atomically? |
Moving ColdFusion’s session scope to Redis addresses one part of that table. It does not establish that an engine’s challenge cache, native CSRF validation state, or result-token handling is also distributed. We observed node-local native CSRF validation state even with Redis-backed sessions. We also found that the configured ColdFusion server cache was local to an engine. Consequently, changing this setting:
challengeStore: "servercache"did not, by itself, make challenges available across nodes. The cache behind that name must actually be shared. Changing a global server-cache configuration can also affect other application caching, so treat it as infrastructure work with consequences beyond passkeys. Even after sharing challenges, test the entire ceremony across nodes, including the native service POST and result callback. Do not infer that every other native state store follows the challenge-store setting.
Part 3’s database-backed handoff solves the central authorization-code exchange. It cannot repair native state that disappears before the authentication origin produces that code.
The Practical Deployment Used Stickiness
Our recorded deployment used:
Native challenge store: memory
ALB stickiness type: lb_cookie
Stickiness duration: 300 secondsFor each applicable target group, the relevant attributes were:
stickiness.enabled = true
stickiness.type = lb_cookie
stickiness.lb_cookie.duration_seconds = 300Duration-based ALB stickiness uses a load-balancer cookie to route subsequent requests to the same target. If that target becomes unhealthy or is removed, the ALB can select another target. AWS sticky-session documentation.
Keeping the browser on one healthy engine allowed the ceremony to use that engine’s local state. This was an operational choice with a clear limit: stickiness does not replicate state. If the engine restarts or the target changes during authentication, the in-progress ceremony may be lost. The application should report that authentication could not complete and allow a fresh attempt. Do not extend a challenge indefinitely or skip validation to rescue an interrupted login.
Also keep the two application boundaries from Part 3 in view. Affinity at auth.example.com does not automatically preserve the pending session state at app.example.com. Each application needs an appropriate session strategy. The central authentication design still uses separate application sessions. There is no need to introduce a parent-domain session cookie.
Make Readiness Describe Behavior
A useful deployment check should answer more than “does this file contain the expected hostname?” One of our earlier checks found the hostname in a host-rewrite rule. That rule did not fix the server-port value. The check passed. Authentication did not. For this deployment, readiness needs to cover:
- Native functions: the required passkey BIFs exist.
- Service routing: the local alias resolves to the correct engine files.
- Engine configuration: the intended passkey settings are present.
- Public origin: a request through the external hostname produces the expected scheme, host, and port.
- State strategy: the intended cache, session, and affinity configuration is active.
Keep failures specific. An origin_mismatch is more actionable than “passkeys unavailable.” Log the failing stage, node identifier, and narrowly selected diagnostic values. Avoid logging native result tokens, authorization codes, session cookies, or complete ceremony payloads. During rollout, a readiness script can run in report-only mode while you establish a baseline. Before relying on it as a deployment gate, enable enforcement and verify that a known failure produces a nonzero exit status.
An unreadable configuration is an unknown result. A skipped check is a skipped check. Neither should quietly become evidence that the deployment is ready.
Test Every Target Through the Real Origin
A successful login through the ALB proves that one path worked. It does not prove every node works. Use a controlled staging arrangement to exercise each target while preserving:
https://auth.example.comBrowsing directly to a node’s IP address changes the origin and invalidates the comparison. The final test plan should include:
Test | Expected result |
|---|---|
Register and authenticate through each target | Both complete successfully |
Complete the Part 3 round trip from each relying application | The correct local user receives a session |
Expire a ceremony before completion | Authentication fails cleanly; a fresh attempt works |
Replay a consumed handoff code | Redemption fails |
Submit a mismatched state value | The relying application rejects the callback |
Restart a target during a ceremony | No unauthorized session; recovery starts a new ceremony |
Change nodes between ceremony steps | Succeeds only if the claimed distributed-state design supports it |
Restart or update the engine | Origin, routing, and passkey configuration remain correct |
Run the actual browser flow as well as passive readiness checks. A correct port, a healthy datasource, and an existing CFC are necessary ingredients. They do not prove the complete authentication path works.
Completing the Series
The responsibilities established in Part 1 still hold. ColdFusion handles the native WebAuthn ceremony and credential processing. The application controls user identity, intent, authorization, and its own authenticated session.
Part 3 added a central authentication origin and a narrowly controlled handoff between applications.
The load balancer adds one more responsibility: preserving the request’s public identity and routing it consistently with the state model you actually deployed. For us, getting that right required correcting proxy-derived origin values, handling a runtime-specific listener-port problem, keeping ColdFusion’s endpoint inside the authentication application, provisioning each engine, and using affinity where native state remained local. None of those changes required weakening origin validation or bypassing CSRF protection.
Once those pieces agree, a passkey login can finally become what we wanted in the first place: uneventful and boring.