Authentication

On this page

EmDash uses passkey authentication as its primary login method. Passkeys are phishing-resistant, don’t require passwords, and work across devices through your browser or password manager.

Beyond passkeys, you can add pluggable login providers. GitHub and Google are included with EmDash. The separately installed Atmosphere provider adds AT Protocol accounts, and the same provider interface is open to other packages. The documented GitHub, Google, and Atmosphere providers can create the first admin account or sign in a linked EmDash user.

For Cloudflare deployments, Cloudflare Access is a separate, exclusive authentication mode in production. It validates Access credentials on protected EmDash routes rather than showing the EmDash login methods.

Choose an authentication mode

Passkeys use WebAuthn, a web standard that creates public-key credentials stored on your device or synced through your password manager. When you log in, your device proves possession of the credential without ever sending a password over the network.

Passkeys are the default. GitHub, Google, and Atmosphere providers are additional login methods: each one authenticates the user, links or creates an EmDash account, and establishes the same EmDash session used by a passkey login.

Passkey authentication provides:

  • No passwords to remember or leak
  • Phishing-resistant — credentials are bound to your site’s domain
  • Cross-device sync — works with iCloud Keychain, Google Password Manager, 1Password, etc.
  • Fast login — one tap with biometrics or PIN

Cloudflare Access uses the auth option instead of authProviders. In production it becomes the authority for protected /_emdash routes. EmDash still stores a local user so roles, ownership, and disabled-user checks continue to work.

Set up the first user

The first time you access the admin panel, the Setup Wizard guides you through creating your admin account.

  1. Navigate to http://localhost:4321/_emdash/admin

  2. On Set up your site, enter the site title and optional tagline. A template can also offer sample content. Select Continue.

  3. On Create your account, enter your email address and optional name. Select Continue.

  4. On Secure your account, create a passkey or choose one of the configured login providers. If you choose a passkey, your browser asks where to save it:

    • On macOS: Touch ID, device password, or security key
    • On Windows: Windows Hello or security key
    • On mobile: Face ID, fingerprint, or PIN
  5. Complete the browser or provider flow. EmDash creates the first user as Admin and opens the dashboard.

Log in with a passkey

After setup, returning to the admin panel triggers passkey authentication:

  1. Visit /_emdash/admin

  2. If not logged in, you’ll see the login page

  3. Click Sign in to authenticate

  4. Your browser prompts for your passkey (biometrics, PIN, or security key)

  5. After verification, you’re redirected to the admin dashboard

If you cannot use your passkey, a magic link provides an alternative. The site must have an email provider configured before EmDash can send the link.

  1. On the login page, click Sign in with email

  2. Enter your email address

  3. Check your inbox for a login link

  4. Click the link to authenticate (valid for 15 minutes)

Configure login providers

In addition to passkeys, EmDash supports pluggable login providers that appear on the login page and in the setup wizard. GitHub and Google are included with EmDash. Atmosphere and third-party providers are separate packages that register through the same interface.

Providers are additive — passkeys keep working when providers are enabled. GitHub and Google automatically link an existing EmDash user only when the provider supplies the same verified email address. Atmosphere accounts are linked by their decentralized identifier (DID), because EmDash’s Atmosphere flow does not receive an email address. Each included provider can create the first user, so a fresh install can skip passkeys entirely.

Add providers to Astro

Pass providers to the authProviders array on the EmDash integration. The following example enables GitHub, Google, and Atmosphere:

import { defineConfig } from "astro/config";
import emdash from "emdash/astro";
import { github } from "emdash/auth/providers/github";
import { google } from "emdash/auth/providers/google";
import { atproto } from "@emdash-cms/auth-atproto";

export default defineConfig({
	integrations: [
		emdash({
			authProviders: [github(), google(), atproto()],
		}),
	],
});

Order matters for the login page: providers render in the order you list them, with compact button-only providers first and providers that need a custom form (like Atmosphere, which asks for a handle) shown after.

GitHub

The following example enables the GitHub provider:

import { github } from "emdash/auth/providers/github";

emdash({ authProviders: [github()] });

Set credentials via environment variables. EmDash checks the prefixed names first and falls back to the unprefixed ones:

VariablePurpose
EMDASH_OAUTH_GITHUB_CLIENT_ID / GITHUB_CLIENT_IDOAuth app client ID
EMDASH_OAUTH_GITHUB_CLIENT_SECRET / GITHUB_CLIENT_SECRETOAuth app secret

Configure your GitHub OAuth app’s callback URL as https://your-site.example.com/_emdash/api/auth/oauth/github/callback.

Google

The following example enables the Google provider:

import { google } from "emdash/auth/providers/google";

emdash({ authProviders: [google()] });

Set credentials via environment variables. EmDash checks the prefixed names first and falls back to the unprefixed ones:

VariablePurpose
EMDASH_OAUTH_GOOGLE_CLIENT_ID / GOOGLE_CLIENT_IDOAuth app client ID
EMDASH_OAUTH_GOOGLE_CLIENT_SECRET / GOOGLE_CLIENT_SECRETOAuth app secret

Configure your Google OAuth client’s redirect URI as https://your-site.example.com/_emdash/api/auth/oauth/google/callback.

Atmosphere (AT Protocol)

For sites where contributors already have an Atmosphere account — the user-owned identity behind Bluesky and the wider AT Protocol network — install the Atmosphere provider:

pnpm add @emdash-cms/auth-atproto

The following example enables the Atmosphere provider with a handle allowlist:

import { atproto } from "@emdash-cms/auth-atproto";

emdash({
	authProviders: [
		atproto({
			allowedHandles: ["*.example.com"],
		}),
	],
});

No client secret or environment variable is needed. See the Atmosphere login guide for handle/DID allowlists, role mapping, and the local-development setup that the AT Protocol OAuth profile requires.

Build a provider

A provider is an AuthProviderDescriptor: an id, a human label, and the admin components, route handlers, public route prefixes, and storage collections that its login flow needs. Export a SetupStep from adminEntry if the provider should appear during first-user setup. The shape is exported from emdash:

import type { AuthProviderDescriptor } from "emdash";

export function myProvider(): AuthProviderDescriptor {
	return {
		id: "my-provider",
		label: "My Provider",
		adminEntry: "my-provider/admin", // exports LoginButton / LoginForm / SetupStep
		routes: [
			{ pattern: "/_emdash/api/auth/my-provider/login", entrypoint: "my-provider/routes/login.ts" },
			{ pattern: "/_emdash/api/auth/my-provider/callback", entrypoint: "my-provider/routes/callback.ts" },
		],
		publicRoutes: ["/_emdash/api/auth/my-provider/"],
		storage: {
			sessions: {},
		},
	};
}

The Atmosphere package (@emdash-cms/auth-atproto) is the most complete real-world reference for a provider that needs a custom login form, OAuth route handlers, and persistent storage.

User roles

EmDash uses role-based access control with five levels:

RoleLevelDescription
Subscriber10Read published content (no draft access)
Contributor20Create content (needs approval to publish)
Author30Create/edit/publish own content
Editor40Manage all content
Admin50Full access including settings

Each role inherits permissions from all lower levels. The first user is always created as Admin.

Subscribers and draft content

Subscribers hold the content:read permission so member-only published content can be served to authenticated readers. They cannot see drafts, scheduled items, trashed items, revisions, or preview URLs — those are gated on content:read_drafts, granted to Contributor and above. The list and get endpoints transparently filter to status=published for Subscribers; editor-only views (/compare, /revisions, /trash, /preview-url) reject Subscriber requests outright.

Invite users

Admins can invite new users via the admin panel:

  1. Go to Settings > Users

  2. Click Invite User

  3. Enter the user’s email and select a role

  4. Click Send Invite

  5. If email is configured, EmDash sends the invite. Otherwise, copy the generated link and send it to the user yourself.

  6. They open the link and create the account with a passkey or a login provider offered on the invite page.

Invite links are single-use and expire after 7 days.

Manage passkeys

Users can manage their passkeys from the account settings:

  • Add passkey — Register additional passkeys for backup or other devices
  • Remove passkey — Delete passkeys you no longer use
  • Rename passkey — Give passkeys descriptive names

Each user can have up to 10 passkeys registered.

EmDash does not let a user remove their last passkey. Add a replacement before deleting the old one.

Letting a group sign in without invites

To let a group sign in without inviting each user, configure a login provider with an allowlist. The Atmosphere provider accepts allowedHandles and allowedDIDs (see Atmosphere login); the Cloudflare Access adapter provisions users from your identity provider via autoProvision and roleMapping. The documented GitHub, Google, and Atmosphere providers can also create the initial admin account.

Sessions

Passkey, magic-link, invite, and login-provider callbacks store the EmDash user ID in Astro’s session store. The browser receives Astro’s opaque astro-session identifier; user and credential records remain in the EmDash database.

Cloudflare Access also writes the resolved EmDash user to the Astro session. That lets public pages identify a signed-in user when they read Astro.locals.user. The session does not replace Access authentication on protected /_emdash routes: EmDash validates the Access JSON Web Token (JWT) again on those requests.

Authentication rate limits

EmDash limits the endpoints that begin unauthenticated login or signup flows. The limits are separate for each endpoint and trusted client IP:

EndpointLimit
POST /_emdash/api/auth/passkey/options10 requests per minute
POST /_emdash/api/auth/magic-link/send3 requests per 5 minutes
POST /_emdash/api/auth/signup/request3 requests per 5 minutes

On Cloudflare, EmDash reads the client IP from Cloudflare’s request metadata. A self-hosted site behind a reverse proxy must configure trustedProxyHeaders before EmDash can use the proxy’s client-IP header. When no trusted IP is available, these per-IP checks are skipped because there is no safe key to count.

Passkeys store public-key credentials; the private key stays with the user’s authenticator. Magic-link tokens are stored as SHA-256 hashes and deleted after use.

Troubleshooting

”No passkeys registered”

If you see this error on login, your passkey may have been deleted from your password manager. Ask an admin to send a recovery magic link; the site must have email configured.

”Passkey authentication failed”

This usually means the passkey was created for a different domain. Passkeys are domain-bound — a passkey for localhost:4321 won’t work on example.com. Register a new passkey for each domain.

Lost all passkeys

If you’ve lost access to all your registered passkeys:

  1. Ask another admin to send a recovery magic link. The site must have email configured.
  2. Use the link within 15 minutes to log in.
  3. Register a new passkey in account settings.

If you’re the only admin and email isn’t configured, you’ll need to reset your site’s authentication through the database.

Cloudflare Access

When deploying to Cloudflare, you can use Cloudflare Access instead of the built-in login methods. Access authenticates the user at the edge with your identity provider. EmDash validates the signed Access JWT, loads the person’s identity and groups, and maps that identity to a local EmDash user.

When to use Cloudflare Access

  • Single Sign-On — Users authenticate with your company’s IdP
  • Centralized access control — Manage who can access the admin in the Cloudflare dashboard
  • No passkey management — No need to register or manage passkeys
  • Group-based roles — Map IdP groups to EmDash roles automatically

Set up Access

  1. Create a Cloudflare Access application and policy for your site’s /_emdash/* path. Protecting only /_emdash/admin/* leaves the REST API without the JWT that EmDash expects.
  2. Copy the application’s Application Audience (AUD) Tag.
  3. Store the tag in the CF_ACCESS_AUDIENCE runtime environment variable. Follow the EmDash secrets guide for local and deployed values.
  4. Configure EmDash to read that value at runtime:
import { defineConfig } from "astro/config";
import cloudflare from "@astrojs/cloudflare";
import emdash from "emdash/astro";
import { d1, access } from "@emdash-cms/cloudflare";

export default defineConfig({
	output: "server",
	adapter: cloudflare(),
	integrations: [
		emdash({
			database: d1({ binding: "DB" }),
			auth: access({
				teamDomain: "myteam.cloudflareaccess.com",
				audienceEnvVar: "CF_ACCESS_AUDIENCE",
			}),
		}),
	],
});

The application audience identifies which Access application issued the JWT. EmDash verifies it together with the issuer and signature; a token for another Access application is rejected.

Configuration options

OptionTypeDefaultDescription
teamDomainstringrequiredYour Access team domain (e.g., myteam.cloudflareaccess.com)
audiencestringApplication Audience (AUD) tag supplied directly. Prefer audienceEnvVar on Workers.
autoProvisionbooleantrueCreate EmDash users on first Access login
defaultRolenumber30Role for users not matching any group (30 = Author)
syncRolesbooleanfalseUpdate role on each login based on IdP groups
roleMappingobjectMap IdP group names to role levels
audienceEnvVarstring"CF_ACCESS_AUDIENCE"Environment variable containing the audience tag. Used when audience is omitted.

Provide either audience or an environment value under audienceEnvVar.

Role mapping

Map your IdP groups to EmDash roles:

emdash({
	auth: access({
		teamDomain: "myteam.cloudflareaccess.com",
		audienceEnvVar: "CF_ACCESS_AUDIENCE",
		roleMapping: {
			Admins: 50, // Admin
			"Content Editors": 40, // Editor
			Writers: 30, // Author
		},
		defaultRole: 20, // Contributor for users not in any group
	}),
});

The first matching group wins if a user belongs to multiple groups. The first user to access the site always becomes Admin, regardless of groups.

Role sync behavior

By default (syncRoles: false), a user’s role is set when they first log in and doesn’t change afterward. This allows admins to manually adjust roles in EmDash.

Set syncRoles: true if you want IdP groups to be authoritative — the user’s role will update on every login based on their current groups.

Request and session flow

  1. The user visits a path protected by the Access application.
  2. Cloudflare Access redirects the user to your identity provider when no Access session exists.
  3. After authentication, Access sends a signed JWT to the origin in Cf-Access-Jwt-Assertion.
  4. EmDash validates the token’s signature, issuer, and audience, then reads the Access identity and groups.
  5. EmDash finds or provisions the local user, applies the configured role behavior, and records the user in the Astro session.
  6. Later requests to protected EmDash routes repeat Access validation. Public pages can use the EmDash session to identify the user without treating it as proof of a new Access request.

Features replaced by Access

When Access is enabled, these features are unavailable:

  • Login page (/_emdash/admin/login)
  • Passkey registration and management
  • GitHub, Google, and Atmosphere login
  • Magic link login
  • Self-signup
  • User invites

Access policies decide who reaches EmDash. EmDash still owns local roles, content ownership, and the disabled-user flag. With syncRoles: false, administrators can change a provisioned user’s role in EmDash. With syncRoles: true, the mapped Access groups replace that role on each login.

Troubleshooting

”No Access JWT present”

The request reached EmDash without an Access JWT. This means:

  • Access isn’t configured to protect your application
  • The Access policy isn’t matching the admin routes

Verify that the Access application covers the full /_emdash/* path and that its policy includes the user.

”JWT audience mismatch”

The audience in your config doesn’t match the JWT. Double-check the Application Audience Tag in your Access application settings.

”User not authorized”

The user authenticated via Access but autoProvision is false and they don’t exist in EmDash. Either:

  • Set autoProvision: true, or
  • Create the user manually before they log in