Authentication
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, Google, and the Atmosphere (AT Protocol) ship out of the box, and the same provider interface is open for third-party packages. Any configured provider can be used to create the first admin account, log in, or link to an existing user.
For Cloudflare deployments, Cloudflare Access is also available as an exclusive auth method that takes over the entire login flow.
How It Works
Section titled “How It Works”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.
Benefits of passkey authentication:
- 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
First User Setup
Section titled “First User Setup”The first time you access the admin panel, the Setup Wizard guides you through creating your admin account.
-
Navigate to
http://localhost:4321/_emdash/admin -
You’ll be redirected to the Setup Wizard. Enter:
- Site Title — Your site’s name
- Tagline — A short description
- Admin Email — Your email address
-
Click Create Site to register your passkey
-
Your browser will prompt you to create a passkey:
- On macOS: Touch ID, device password, or security key
- On Windows: Windows Hello or security key
- On mobile: Face ID, fingerprint, or PIN
-
Once your passkey is registered, you’re logged in and redirected to the admin dashboard.
Logging In
Section titled “Logging In”After setup, returning to the admin panel triggers passkey authentication:
-
Visit
/_emdash/admin -
If not logged in, you’ll see the login page
-
Click Sign in to authenticate
-
Your browser prompts for your passkey (biometrics, PIN, or security key)
-
After verification, you’re redirected to the admin dashboard
Magic Link Fallback
Section titled “Magic Link Fallback”If you can’t use your passkey (e.g., lost device), magic links provide an alternative. This requires email to be configured.
-
On the login page, click Sign in with email
-
Enter your email address
-
Check your inbox for a login link
-
Click the link to authenticate (valid for 15 minutes)
Login Providers
Section titled “Login Providers”In addition to passkeys, EmDash supports pluggable login providers that appear on the login page and in the setup wizard. GitHub, Google, and Atmosphere providers ship in the box; third-party packages can register their own using the same interface.
Providers are additive — passkeys keep working when providers are enabled, and users can link a provider to an existing passkey-only account. The first user can also be created through any configured provider, so a fresh install can skip passkeys entirely if you prefer.
Configuring providers
Section titled “Configuring providers”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
Section titled “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:
| Variable | Purpose |
|---|---|
EMDASH_OAUTH_GITHUB_CLIENT_ID / GITHUB_CLIENT_ID | OAuth app client ID |
EMDASH_OAUTH_GITHUB_CLIENT_SECRET / GITHUB_CLIENT_SECRET | OAuth app secret |
Configure your GitHub OAuth app’s callback URL as https://your-site.example.com/_emdash/api/auth/oauth/github/callback.
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:
| Variable | Purpose |
|---|---|
EMDASH_OAUTH_GOOGLE_CLIENT_ID / GOOGLE_CLIENT_ID | OAuth app client ID |
EMDASH_OAUTH_GOOGLE_CLIENT_SECRET / GOOGLE_CLIENT_SECRET | OAuth 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)
Section titled “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-atprotoThe 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.
Building your own provider
Section titled “Building your own provider”A provider is just an AuthProviderDescriptor — an id, a human label, and any combination of admin-side React components, route handlers, public route prefixes, and storage collections. 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
Section titled “User Roles”EmDash uses role-based access control with five levels:
| Role | Level | Description |
|---|---|---|
| Subscriber | 10 | Read published content (no draft access) |
| Contributor | 20 | Create content (needs approval to publish) |
| Author | 30 | Create/edit/publish own content |
| Editor | 40 | Manage all content |
| Admin | 50 | Full access including settings |
Each role inherits permissions from all lower levels. The first user is always created as Admin.
Subscribers and draft content
Section titled “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.
Inviting Users
Section titled “Inviting Users”Admins can invite new users via the admin panel:
-
Go to Settings > Users
-
Click Invite User
-
Enter the user’s email and select a role
-
Click Send Invite
-
The user receives an email with an invite link
-
They click the link and register their passkey
Invites are valid for 7 days. Admins can resend or revoke invites from the Users page.
Managing Passkeys
Section titled “Managing 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.
Letting a group sign in without invites
Section titled “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. Any configured provider can also create the initial admin account.
Sessions
Section titled “Sessions”Sessions use secure, HttpOnly, SameSite=Lax cookies and last 30 days with sliding expiration — the expiry resets on activity.
Security Notes
Section titled “Security Notes”- Passkeys are stored as public keys — the private key never leaves your device
- Challenge verification prevents replay attacks
- Rate limiting protects against brute force (5 attempts/minute/IP)
- Sessions are HttpOnly, Secure, SameSite=Lax for cookie security
- Magic link tokens are SHA-256 hashed — raw tokens are never stored
Troubleshooting
Section titled “Troubleshooting””No passkeys registered”
Section titled “”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 you a magic link or new invite.
”Passkey authentication failed”
Section titled “”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.
”Session expired”
Section titled “”Session expired””Sessions last 30 days by default with sliding expiration. If you’re logged out unexpectedly, clear your cookies and log in again.
Lost all passkeys
Section titled “Lost all passkeys”If you’ve lost access to all your registered passkeys:
- Ask another admin to send you a magic link (requires email configuration)
- Use the magic link to log in
- 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
Section titled “Cloudflare Access”When deploying to Cloudflare, you can use Cloudflare Access as your authentication provider instead of passkeys. Access handles authentication at the edge using your existing identity provider.
Why Use Cloudflare Access
Section titled “Why 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
- Create a Cloudflare Access application for your EmDash site
- Note the Application Audience (AUD) Tag from the application settings
- Configure EmDash to use Access:
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", audience: "abc123def456...", // From Access app settings }), }), ],});Configuration Options
Section titled “Configuration Options”| Option | Type | Default | Description |
|---|---|---|---|
teamDomain | string | required | Your Access team domain (e.g., myteam.cloudflareaccess.com) |
audience | string | required | Application Audience (AUD) tag from Access settings |
autoProvision | boolean | true | Create EmDash users on first Access login |
defaultRole | number | 30 | Role for users not matching any group (30 = Author) |
syncRoles | boolean | false | Update role on each login based on IdP groups |
roleMapping | object | — | Map IdP group names to role levels |
audienceEnvVar | string | "CF_ACCESS_AUDIENCE" | Environment variable name for the audience tag (alternative to hardcoding) |
Role Mapping
Section titled “Role Mapping”Map your IdP groups to EmDash roles:
emdash({ auth: access({ teamDomain: "myteam.cloudflareaccess.com", audience: "abc123...", 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
Section titled “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.
How It Works
Section titled “How It Works”- User visits
/_emdash/admin - Cloudflare Access intercepts and redirects to your IdP
- User authenticates (SSO, MFA, etc.)
- Access sets a signed JWT in the request
- EmDash validates the JWT and creates/authenticates the user
Disabled Features
Section titled “Disabled Features”When Access is enabled, these features are unavailable:
- Login page (
/_emdash/admin/login) - Passkey registration and management
- OAuth login
- Magic link login
- Self-signup
- User invites
User management is done entirely through your Cloudflare Access policies.
Troubleshooting
Section titled “Troubleshooting””No Access JWT present”
Section titled “”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 your Access application covers /_emdash/admin/*.
”JWT audience mismatch”
Section titled “”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”
Section titled “”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