-
Passkeys Not Supported
+
Passkeys Not Available Here
- Your browser doesn't support passkeys. Please use a modern browser like Chrome, Safari,
- Firefox, or Edge.
+ {insecureContext ? (
+ <>
+ Passkeys require a secure context: use{" "}
+ HTTPS, or open the admin at{" "}
+ http://localhost (with your dev port).
+ Plain http:// on a custom hostname is not treated as
+ secure, even on loopback.
+ >
+ ) : (
+ <>
+ Your browser doesn't support passkeys. Please use a modern browser like Chrome,
+ Safari, Firefox, or Edge.
+ >
+ )}
);
diff --git a/packages/admin/src/components/auth/PasskeyRegistration.tsx b/packages/admin/src/components/auth/PasskeyRegistration.tsx
index e79b525..48cc607 100644
--- a/packages/admin/src/components/auth/PasskeyRegistration.tsx
+++ b/packages/admin/src/components/auth/PasskeyRegistration.tsx
@@ -15,6 +15,10 @@ import { Button, Input } from "@cloudflare/kumo";
import * as React from "react";
import { apiFetch, parseApiResponse } from "../../lib/api/client";
+import {
+ isPasskeyEnvironmentUsable,
+ isWebAuthnSecureContext,
+} from "../../lib/webauthn-environment";
// ============================================================================
// Constants
@@ -95,17 +99,6 @@ type RegistrationState =
| { status: "error"; message: string }
| { status: "success" };
-/**
- * Check if WebAuthn is supported in the current browser
- */
-function isWebAuthnSupported(): boolean {
- return (
- typeof window !== "undefined" &&
- window.PublicKeyCredential !== undefined &&
- typeof window.PublicKeyCredential === "function"
- );
-}
-
/**
* Convert base64url to ArrayBuffer
*/
@@ -153,8 +146,12 @@ export function PasskeyRegistration({
});
const [passkeyName, setPasskeyName] = React.useState("");
- // Check WebAuthn support on mount
- const isSupported = React.useMemo(() => isWebAuthnSupported(), []);
+ // Secure context (HTTPS or http://localhost) + PublicKeyCredential
+ const isSupported = React.useMemo(() => isPasskeyEnvironmentUsable(), []);
+ const insecureContext = React.useMemo(
+ () => typeof window !== "undefined" && !isWebAuthnSecureContext(),
+ [],
+ );
const handleRegister = React.useCallback(async () => {
if (!isSupported) {
@@ -292,14 +289,26 @@ export function PasskeyRegistration({
onError,
]);
- // Not supported message
+ // Not usable (insecure origin vs missing API — browser hides WebAuthn the same way)
if (!isSupported) {
return (
-
Passkeys Not Supported
+
Passkeys Not Available Here
- Your browser doesn't support passkeys. Please use a modern browser like Chrome, Safari,
- Firefox, or Edge.
+ {insecureContext ? (
+ <>
+ Passkeys require a secure context: use{" "}
+ HTTPS, or open the admin at{" "}
+ http://localhost (with your dev port).
+ Plain http:// on a custom hostname is not treated as
+ secure, even on loopback.
+ >
+ ) : (
+ <>
+ Your browser doesn't support passkeys. Please use a modern browser like Chrome,
+ Safari, Firefox, or Edge.
+ >
+ )}
);
diff --git a/packages/admin/src/lib/webauthn-environment.ts b/packages/admin/src/lib/webauthn-environment.ts
new file mode 100644
index 0000000..60f13f5
--- /dev/null
+++ b/packages/admin/src/lib/webauthn-environment.ts
@@ -0,0 +1,25 @@
+/**
+ * WebAuthn is only available in a browser "secure context": HTTPS, or special-cased
+ * loopback hosts such as `http://localhost` / `http://127.0.0.1`.
+ *
+ * An origin like `http://emdash.local:8081` resolves to 127.0.0.1 but is still
+ * **not** a secure context, so `PublicKeyCredential` is hidden — the same symptom
+ * as an unsupported browser.
+ */
+
+export function isWebAuthnSecureContext(): boolean {
+ return typeof window !== "undefined" && window.isSecureContext;
+}
+
+export function isPublicKeyCredentialConstructorAvailable(): boolean {
+ return (
+ typeof window !== "undefined" &&
+ window.PublicKeyCredential !== undefined &&
+ typeof window.PublicKeyCredential === "function"
+ );
+}
+
+/** True when the page can use `navigator.credentials` for passkeys. */
+export function isPasskeyEnvironmentUsable(): boolean {
+ return isWebAuthnSecureContext() && isPublicKeyCredentialConstructorAvailable();
+}
diff --git a/packages/admin/tests/lib/webauthn-environment.test.ts b/packages/admin/tests/lib/webauthn-environment.test.ts
new file mode 100644
index 0000000..e369e47
--- /dev/null
+++ b/packages/admin/tests/lib/webauthn-environment.test.ts
@@ -0,0 +1,54 @@
+import { describe, it, expect, afterEach } from "vitest";
+
+import {
+ isPasskeyEnvironmentUsable,
+ isPublicKeyCredentialConstructorAvailable,
+ isWebAuthnSecureContext,
+} from "../../src/lib/webauthn-environment";
+
+describe("webauthn-environment", () => {
+ const origPk = globalThis.window.PublicKeyCredential;
+ const desc = Object.getOwnPropertyDescriptor(globalThis.window, "isSecureContext");
+
+ afterEach(() => {
+ if (origPk === undefined) {
+ delete (globalThis.window as { PublicKeyCredential?: unknown }).PublicKeyCredential;
+ } else {
+ Object.defineProperty(globalThis.window, "PublicKeyCredential", {
+ value: origPk,
+ configurable: true,
+ writable: true,
+ });
+ }
+ if (desc) Object.defineProperty(globalThis.window, "isSecureContext", desc);
+ });
+
+ it("is usable only when secure context and PublicKeyCredential constructor exist", () => {
+ Object.defineProperty(globalThis.window, "isSecureContext", {
+ value: true,
+ configurable: true,
+ });
+ Object.defineProperty(globalThis.window, "PublicKeyCredential", {
+ value: function PublicKeyCredential() {},
+ configurable: true,
+ writable: true,
+ });
+ expect(isWebAuthnSecureContext()).toBe(true);
+ expect(isPublicKeyCredentialConstructorAvailable()).toBe(true);
+ expect(isPasskeyEnvironmentUsable()).toBe(true);
+ });
+
+ it("is not usable in an insecure context even if PublicKeyCredential is defined", () => {
+ Object.defineProperty(globalThis.window, "isSecureContext", {
+ value: false,
+ configurable: true,
+ });
+ Object.defineProperty(globalThis.window, "PublicKeyCredential", {
+ value: function PublicKeyCredential() {},
+ configurable: true,
+ writable: true,
+ });
+ expect(isWebAuthnSecureContext()).toBe(false);
+ expect(isPasskeyEnvironmentUsable()).toBe(false);
+ });
+});
diff --git a/packages/core/src/astro/integration/index.ts b/packages/core/src/astro/integration/index.ts
index 14b56d6..3ed69a2 100644
--- a/packages/core/src/astro/integration/index.ts
+++ b/packages/core/src/astro/integration/index.ts
@@ -90,6 +90,22 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration {
}
}
+ if (resolvedConfig.passkeyPublicOrigin) {
+ const raw = resolvedConfig.passkeyPublicOrigin;
+ try {
+ const parsed = new URL(raw);
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
+ throw new Error(`passkeyPublicOrigin must be http or https (got ${parsed.protocol})`);
+ }
+ resolvedConfig.passkeyPublicOrigin = parsed.origin;
+ } catch (e) {
+ if (e instanceof TypeError) {
+ throw new Error(`Invalid passkeyPublicOrigin: "${raw}"`, { cause: e });
+ }
+ throw e;
+ }
+ }
+
// Plugin descriptors from config
const pluginDescriptors = resolvedConfig.plugins ?? [];
const sandboxedDescriptors = resolvedConfig.sandboxed ?? [];
@@ -136,6 +152,7 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration {
storage: resolvedConfig.storage,
auth: resolvedConfig.auth,
marketplace: resolvedConfig.marketplace,
+ passkeyPublicOrigin: resolvedConfig.passkeyPublicOrigin,
};
// Determine auth mode for route injection
diff --git a/packages/core/src/astro/integration/runtime.ts b/packages/core/src/astro/integration/runtime.ts
index 63efaef..7b000d7 100644
--- a/packages/core/src/astro/integration/runtime.ts
+++ b/packages/core/src/astro/integration/runtime.ts
@@ -262,6 +262,19 @@ export interface EmDashConfig {
*/
marketplace?: string;
+ /**
+ * Public browser origin for passkey verification (rpId + expected WebAuthn origin).
+ *
+ * Use when `Astro.url` / `request.url` do not match what users open — common with a
+ * **TLS-terminating reverse proxy**: the app often sees `http://` on the internal hop
+ * while the browser uses `https://`, which breaks WebAuthn origin checks.
+ *
+ * Set to the full origin users type in the address bar (no path), e.g.
+ * `https://emdash.local:8443`. Prefer fixing `security.allowedDomains` for the proxy first;
+ * use this when the reconstructed URL still diverges from the browser.
+ */
+ passkeyPublicOrigin?: string;
+
/**
* Enable playground mode for ephemeral "try EmDash" sites.
*
diff --git a/packages/core/src/astro/routes/api/auth/invite/complete.ts b/packages/core/src/astro/routes/api/auth/invite/complete.ts
index 9a49822..8333afb 100644
--- a/packages/core/src/astro/routes/api/auth/invite/complete.ts
+++ b/packages/core/src/astro/routes/api/auth/invite/complete.ts
@@ -22,6 +22,7 @@ import { OptionsRepository } from "#db/repositories/options.js";
export const POST: APIRoute = async ({ request, locals, session }) => {
const { emdash } = locals;
+ const passkeyPublicOrigin = emdash?.config.passkeyPublicOrigin;
if (!emdash?.db) {
return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
@@ -37,7 +38,7 @@ export const POST: APIRoute = async ({ request, locals, session }) => {
const url = new URL(request.url);
const options = new OptionsRepository(emdash.db);
const siteName = (await options.get