Skip to content

fix: passkeys behind TLS reverse proxy#225

Merged
ascorbic merged 4 commits intoemdash-cms:mainfrom
shipstuff:fix/reverse-proxy
Apr 6, 2026
Merged

fix: passkeys behind TLS reverse proxy#225
ascorbic merged 4 commits intoemdash-cms:mainfrom
shipstuff:fix/reverse-proxy

Conversation

@seslly
Copy link
Copy Markdown
Contributor

@seslly seslly commented Apr 4, 2026

What does this PR do?

Fixes WebAuthn passkey registration and login when Astro dev (or Node SSR) sits behind a TLS-terminating reverse proxy: the server often sees http:// and an internal host while the browser uses https:// and a public host, so clientData.origin no longer matches the origin EmDash used in verify.

  • Adds passkeyPublicOrigin on emdash(), validated at integration load time, normalized to origin, and threaded through all passkey-related and setup routes that call getPasskeyConfig().
  • Admin: webauthn-environment helpers and clearer PasskeyRegistration / PasskeyLogin copy when the page is not a secure context (e.g. plain http://emdash.local), instead of implying missing browser support.
  • Core tests: getPasskeyConfig coverage, including emulated proxy URL shapes; invalid passkeyPublicOrigin now throws instead of silently falling back.
  • E2E: existing passkey-full-setup-virtual-auth.spec.ts still exercises the full passkey registration path on http://localhost:4444 (secure context); proxy/TLS alignment is covered by passkey-config unit tests, not a second Playwright project.
  • Docs: skill building-emdash-site/references/configuration.md — reverse proxy and passkeys (passkeyPublicOrigin, security.allowedDomains, vite.server.allowedHosts).

Bug discover, repro, and fix done with examples taken from @jeftekhari

Simple repro

Symptom (before): Browser is on https://… (TLS in front), but the server still built WebAuthn origin / rpId from the upstream URL (http://127.0.0.1:… or a different host), so passkey registration/login fails with origin / verification errors.

Automated (no extra repo files — always in tree):

pnpm --filter emdash exec vitest run tests/unit/auth/passkey-config.test.ts

That file exercises getPasskeyConfig / forwarded-host URL shapes and invalid passkeyPublicOrigin (throws).

Manual browser (TLS-terminating reverse proxy):

  1. Put Astro or your built Node server on http://127.0.0.1:<port> (loopback).
  2. Terminate TLS in front with a reverse proxy so the browser uses a single stable https://<host> origin.
  3. In astro.config, set security.allowedDomains / vite.server.allowedHosts for that public host and emdash({ passkeyPublicOrigin: "https://<host>" }) to match the browser origin (see skills/building-emdash-site/references/configuration.md § Reverse proxy and passkeys).
  4. Exercise setup/login passkeys in the browser (or POST /_emdash/api/auth/passkey/options with the same Host / Origin your browser would send).

Heavier manual repro (full stack)

Goal: browser hits https://<public-host>:<edge-port> while Astro (e.g. demos/simple) listens on http://127.0.0.1:4321

  1. Reverse proxy terminates TLS and forwards to upstream with port-preserving Host / X-Forwarded-Host (if your proxy strips the port from Host, Astro’s rebuilt URL and rpId will be wrong — see demos/simple/reverse-proxy/README.md § Forwarding).
  2. astro.config: security.allowedDomains (and dev vite.server.allowedHosts) for that public hostname + schemes; emdash({ passkeyPublicOrigin: "https://<public-host>:<edge-port>" }) when the internal request URL is still http:// or otherwise diverges from the tab’s origin (skills/building-emdash-site/references/configuration.md § Reverse proxy and passkeys).
  3. Check: open admin/setup over HTTPS and complete passkey registration, or POST /_emdash/api/auth/passkey/options with the same Host / Origin the browser sends — expect rpId = hostname of the user-facing origin, no “Invalid origin” during verify.

Closes #210

Type of change

  • Bug fix
  • Feature (requires approved Discussion)
  • Refactor (no behavior change)
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm --silent lint:json | jq '.diagnostics | length' returns 0
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion: https://github.com/emdash-cms/emdash/discussions/...

AI-generated code disclosure

  • This PR includes AI-generated code

Screenshots / test output

Optional: pnpm --filter emdash exec vitest run tests/unit/auth/passkey-config.test.ts and/or pnpm exec playwright test e2e/tests/passkey-full-setup-virtual-auth.spec.ts.

Add passkeyPublicOrigin and wire it through passkey routes so origin/rpId match
the browser when dev runs behind nginx. Expose dev-only /_emdash/api/dev/passkey-url,
add admin messaging for insecure WebAuthn contexts, nginx repro under demos/simple,
and direct kysely dependency for the simple demo Node adapter bundle.

Made-with: Cursor
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 4, 2026

🦋 Changeset detected

Latest commit: a00cbab

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 10 packages
Name Type
emdash Patch
@emdash-cms/admin Patch
@emdash-cms/cloudflare Patch
@emdash-cms/plugin-ai-moderation Patch
@emdash-cms/plugin-atproto Patch
@emdash-cms/plugin-audit-log Patch
@emdash-cms/plugin-color Patch
@emdash-cms/plugin-embeds Patch
@emdash-cms/plugin-forms Patch
@emdash-cms/plugin-webhook-notifier Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 4, 2026

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@seslly
Copy link
Copy Markdown
Contributor Author

seslly commented Apr 4, 2026

I have read the CLA Document and I hereby sign the CLA

@jeftekhari
Copy link
Copy Markdown
Contributor

This is absolutely necessary for self hosted users.

@ascorbic ascorbic requested a review from Copilot April 4, 2026 19:03
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Apr 4, 2026

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@225

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@225

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@225

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@225

emdash

npm i https://pkg.pr.new/emdash@225

create-emdash

npm i https://pkg.pr.new/create-emdash@225

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@225

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@225

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@225

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@225

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@225

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@225

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@225

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@225

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@225

commit: a00cbab

@ascorbic
Copy link
Copy Markdown
Collaborator

ascorbic commented Apr 4, 2026

This is great, thanks! Could you update the docs here: https://github.com/emdash-cms/emdash/blob/main/docs/src/content/docs/reference/configuration.mdx

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes WebAuthn passkey registration/login when EmDash (Astro dev or Node SSR) is running behind a TLS-terminating reverse proxy by allowing the server-side WebAuthn origin/rpId to be aligned with the browser-facing public origin.

Changes:

  • Add passkeyPublicOrigin to the emdash() Astro integration config, validate/normalize it to .origin, and propagate it through passkey/setup API routes via getPasskeyConfig().
  • Improve Admin UI passkey messaging by detecting insecure contexts (non-secure origins where WebAuthn is unavailable) using new webauthn-environment helpers.
  • Add/expand unit and e2e coverage for passkey config behavior and end-to-end passkey setup using a Playwright virtual authenticator.

Reviewed changes

Copilot reviewed 21 out of 22 changed files in this pull request and generated no comments.

Show a summary per file
File Description
skills/building-emdash-site/references/configuration.md Documents reverse-proxy/TLS guidance for passkeys and host allowlists.
packages/core/tests/unit/auth/passkey-config.test.ts Adds unit coverage for proxy URL shapes and invalid passkeyPublicOrigin.
packages/core/src/auth/passkey-config.ts Adds optional passkeyPublicOrigin override for WebAuthn origin/rpId.
packages/core/src/astro/routes/api/setup/admin.ts Threads passkeyPublicOrigin into setup registration options.
packages/core/src/astro/routes/api/setup/admin-verify.ts Threads passkeyPublicOrigin into setup registration verification.
packages/core/src/astro/routes/api/auth/signup/complete.ts Threads passkeyPublicOrigin into signup passkey verification.
packages/core/src/astro/routes/api/auth/passkey/options.ts Threads passkeyPublicOrigin into auth options generation.
packages/core/src/astro/routes/api/auth/passkey/verify.ts Threads passkeyPublicOrigin into auth assertion verification.
packages/core/src/astro/routes/api/auth/passkey/register/options.ts Threads passkeyPublicOrigin into registration options generation.
packages/core/src/astro/routes/api/auth/passkey/register/verify.ts Threads passkeyPublicOrigin into registration response verification.
packages/core/src/astro/routes/api/auth/invite/complete.ts Threads passkeyPublicOrigin into invite passkey verification.
packages/core/src/astro/integration/runtime.ts Adds EmDashConfig.passkeyPublicOrigin with inline docs.
packages/core/src/astro/integration/index.ts Validates passkeyPublicOrigin (http/https) and normalizes to .origin.
packages/admin/src/lib/webauthn-environment.ts Adds helpers to detect secure-context + WebAuthn constructor availability.
packages/admin/tests/lib/webauthn-environment.test.ts Tests new WebAuthn environment helpers.
packages/admin/src/components/auth/PasskeyRegistration.tsx Improves UX copy by distinguishing insecure context vs missing WebAuthn API.
packages/admin/src/components/auth/PasskeyLogin.tsx Improves UX copy and uses environment helpers for conditional mediation gating.
e2e/tests/passkey-full-setup-virtual-auth.spec.ts Adds an e2e passkey setup test using a virtual authenticator.
e2e/fixtures/virtual-authenticator.ts Adds CDP helper to register/remove a virtual WebAuthn authenticator.
e2e/fixture/emdash-env.d.ts Updates generated types to include bylines (ContentBylineCredit).
demos/simple/astro.config.mjs Adds commented examples for reverse proxy host allowlists and passkeyPublicOrigin.
.changeset/reverse-proxy-passkey.md Ships the change as patch bumps for emdash and @emdash-cms/admin.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@UpperM
Copy link
Copy Markdown

UpperM commented Apr 4, 2026

I ran into the same passkey issue behind a reverse proxy.

One thing: passkeyPublicOrigin is evaluated at build time in astro.config, so it doesn't work when the origin is only known at runtime (e.g. SITE_URL env var set per container).
I added a process.env.SITE_URL fallback in getPasskeyConfig() on my side. Basically: passkeyPublicOrigin wins if set, then
SITE_URL at runtime, then request URL. Would you be open to that, or should I open a separate PR?

Adds the new passkeyPublicOrigin option and reverse proxy guidance
to the public-facing configuration docs as requested in PR review.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@seslly
Copy link
Copy Markdown
Contributor Author

seslly commented Apr 4, 2026

I ran into the same passkey issue behind a reverse proxy.

One thing: passkeyPublicOrigin is evaluated at build time in astro.config, so it doesn't work when the origin is only known at runtime (e.g. SITE_URL env var set per container). I added a process.env.SITE_URL fallback in getPasskeyConfig() on my side. Basically: passkeyPublicOrigin wins if set, then SITE_URL at runtime, then request URL. Would you be open to that, or should I open a separate PR?

yeah i was debating something like that but i figured since it's so easily set at build time and each site would likely have it's own build step so decided against and seemed safer that way too

i guess are you trying to set the site origin dynamically on start rather than having the process.env available and require a rebuild?

@jeftekhari
Copy link
Copy Markdown
Contributor

I have read the CLA Document and I hereby sign the CLA

github-actions bot added a commit that referenced this pull request Apr 4, 2026
@UpperM
Copy link
Copy Markdown

UpperM commented Apr 4, 2026

I ran into the same passkey issue behind a reverse proxy.
One thing: passkeyPublicOrigin is evaluated at build time in astro.config, so it doesn't work when the origin is only known at runtime (e.g. SITE_URL env var set per container). I added a process.env.SITE_URL fallback in getPasskeyConfig() on my side. Basically: passkeyPublicOrigin wins if set, then SITE_URL at runtime, then request URL. Would you be open to that, or should I open a separate PR?

yeah i was debating something like that but i figured since it's so easily set at build time and each site would likely have it's own build step so decided against and seemed safer that way too

i guess are you trying to set the site origin dynamically on start rather than having the process.env available and require a rebuild?

Exactly, the image is prebuilt once and deployed as-is on multiple containers. Think pulling a prebuilt image on Coolify or Dokploy and just setting SITE_URL as an env var, no rebuild needed.

@seslly
Copy link
Copy Markdown
Contributor Author

seslly commented Apr 4, 2026

I ran into the same passkey issue behind a reverse proxy.
One thing: passkeyPublicOrigin is evaluated at build time in astro.config, so it doesn't work when the origin is only known at runtime (e.g. SITE_URL env var set per container). I added a process.env.SITE_URL fallback in getPasskeyConfig() on my side. Basically: passkeyPublicOrigin wins if set, then SITE_URL at runtime, then request URL. Would you be open to that, or should I open a separate PR?

yeah i was debating something like that but i figured since it's so easily set at build time and each site would likely have it's own build step so decided against and seemed safer that way too
i guess are you trying to set the site origin dynamically on start rather than having the process.env available and require a rebuild?

Exactly, the image is prebuilt once and deployed as-is on multiple containers. Think pulling a prebuilt image on Coolify or Dokploy and just setting SITE_URL as an env var, no rebuild needed.

@ascorbic we updated the docs and fixed some of the failing tests lmk if you need anything else. also for this req would you rather it be added to this pr or a separate one? mostly unsure if this behavior at runtime is what you guys want for the project which is why i didnt just add it

@ascorbic ascorbic requested a review from Copilot April 5, 2026 07:27
@github-actions github-actions bot mentioned this pull request Apr 5, 2026
16 tasks
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 5, 2026

Overlapping PRs

This PR modifies files that are also changed by other open PRs:

This may cause merge conflicts or duplicated work. A maintainer will coordinate.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 23 out of 24 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +203 to +205
### `passkeyPublicOrigin`

Import from `emdash/db`:
**Optional.** Pass a full **browser-facing origin** (scheme + host + optional port, **no path**) so WebAuthn **`rpId`** and **`origin`** match what the user’s browser sends in `clientData.origin`.
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ## Database Adapters section header appears to have been removed here (the file later has ## Storage Adapters, and the ### sqlite/libsql/postgres/d1 adapter docs now sit directly under “Integration Options”). This makes the document structure inconsistent and harder to navigate. Consider reintroducing a ## Database Adapters heading (and any short intro, if desired) immediately before the adapter-specific ### sqlite(config) section.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

my bad I think I lost it in a conflict will fix

@ascorbic
Copy link
Copy Markdown
Collaborator

ascorbic commented Apr 6, 2026

Great work. Can you address the failing test. Copilot's review looks legit too

@jeftekhari
Copy link
Copy Markdown
Contributor

Great work. Can you address the failing test. Copilot's review looks legit too

@ascorbic added missing test doc and restored doc heading. waiting on tests to rerun

Copy link
Copy Markdown
Collaborator

@ascorbic ascorbic left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work. Thanks!

@seslly
Copy link
Copy Markdown
Contributor Author

seslly commented Apr 6, 2026

thanks for the assist @jeftekhari (I had a busy easter sunday)

@ascorbic ascorbic merged commit d211452 into emdash-cms:main Apr 6, 2026
24 checks passed
@seslly seslly deleted the fix/reverse-proxy branch April 6, 2026 06:43
@emdashbot emdashbot bot mentioned this pull request Apr 6, 2026
UpperM added a commit to UpperM/emdash that referenced this pull request Apr 7, 2026
…igin mismatch

Behind a TLS-terminating reverse proxy, url.origin returns the internal
address (http://localhost:4321) instead of the public one
(https://mysite.example.com). PR emdash-cms#225 fixed passkeys only via
passkeyPublicOrigin. This replaces it with siteUrl which fixes all
origin-dependent features: CSRF, auth redirects, OAuth, MCP discovery,
setup wizard, snapshot export, theme preview, sitemap, robots.txt,
and JSON-LD structured data.

- Add siteUrl config option with env var fallback (EMDASH_SITE_URL > SITE_URL)
- Create getPublicOrigin() pure helper (Workers-safe)
- Add dual-origin CSRF matching (internal + public)
- Wire through all 31 affected call sites
- Update docs, skills reference, and changeset

Discussion: emdash-cms#315
Closes: emdash-cms#210

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
UpperM added a commit to UpperM/emdash that referenced this pull request Apr 8, 2026
Restore the original reverse-proxy-passkey.md changeset (from emdash-cms#225).
Add site-url-reverse-proxy.md for this PR's changes.
@emdashbot emdashbot bot mentioned this pull request Apr 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Title: getPasskeyConfig() ignores configured rpId, always uses url.hostname — breaks passkeys behind reverse proxy

5 participants