---
title: Authentication
description: Passkey-first authentication with pluggable providers for GitHub, Google, and the Atmosphere.
---

import { Aside, Steps, Tabs, TabItem, Code } from "@astrojs/starlight/components";

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](/guides/atmosphere-auth/) (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](#cloudflare-access) is also available as an exclusive auth method that takes over the entire login flow.

## 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

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

<Steps>

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

2. You'll be redirected to the Setup Wizard. Enter:
   - **Site Title** — Your site's name
   - **Tagline** — A short description
   - **Admin Email** — Your email address

3. Click **Create Site** to register your passkey

4. 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

5. Once your passkey is registered, you're logged in and redirected to the admin dashboard.

</Steps>

<Aside>
	Your email is stored but not verified during initial setup. You can configure email later to
	enable features like invites and magic link login.
</Aside>

## Logging In

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

<Steps>

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

</Steps>

## 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.

<Steps>

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)

</Steps>

<Aside type="caution">
	Magic links are single-use and expire after 15 minutes. Request a new link if yours has expired.
</Aside>

## 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

Pass providers to the `authProviders` array on the EmDash integration:

```js title="astro.config.mjs"
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

```js
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`.

### Google

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

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

| 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)

For sites where contributors already have an account on the AT Protocol network — the same identity that powers Bluesky and a growing collection of other apps — install the Atmosphere provider:

```bash
pnpm add @emdash-cms/auth-atproto
```

```js
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](/guides/atmosphere-auth/) for handle/DID allowlists, role mapping, and the local-development setup that the AT Protocol OAuth profile requires.

### 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`:

```ts
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:

| 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

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

Admins can invite new users via the admin panel:

<Steps>

1. Go to **Settings** > **Users**

2. Click **Invite User**

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

4. Click **Send Invite**

5. The user receives an email with an invite link

6. They click the link and register their passkey

</Steps>

Invites are valid for 7 days. Admins can resend or revoke invites from the Users page.

## 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.

<Aside type="tip">
	Register passkeys on multiple devices (e.g., laptop and phone) to ensure you always have a way to
	log in.
</Aside>

## Self-Signup

For team sites, you can enable self-signup for specific email domains:

```js title="astro.config.mjs"
import { defineConfig } from "astro/config";
import emdash from "emdash/astro";

export default defineConfig({
	integrations: [
		emdash({
			auth: {
				selfSignup: {
					domains: ["example.com"],
					defaultRole: "contributor",
				},
			},
		}),
	],
});
```

Users with matching email domains can sign up without an invite. They'll receive a verification email and register a passkey to complete signup.

## Session Configuration

Sessions use secure HttpOnly cookies with sensible defaults:

```js title="astro.config.mjs"
emdash({
	auth: {
		session: {
			maxAge: 30 * 24 * 60 * 60, // 30 days (default)
			sliding: true, // Reset expiry on activity
		},
	},
});
```

## 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

### "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"

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"

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

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

1. Ask another admin to send you a magic link (requires email configuration)
2. Use the magic link 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](https://developers.cloudflare.com/cloudflare-one/applications/) as your authentication provider instead of passkeys. Access handles authentication at the edge using your existing identity provider.

<Aside>
	When Cloudflare Access is configured, it becomes the **exclusive** authentication method.
	Passkeys, OAuth, magic links, and self-signup are all disabled.
</Aside>

### 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

### Setup

1. Create a [Cloudflare Access application](https://developers.cloudflare.com/cloudflare-one/applications/configure-apps/self-hosted-apps/) for your EmDash site
2. Note the **Application Audience (AUD) Tag** from the application settings
3. Configure EmDash to use Access:

```js title="astro.config.mjs"
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

| 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

Map your IdP groups to EmDash roles:

```js title="astro.config.mjs"
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

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

1. User visits `/_emdash/admin`
2. Cloudflare Access intercepts and redirects to your IdP
3. User authenticates (SSO, MFA, etc.)
4. Access sets a signed JWT in the request
5. EmDash validates the JWT and creates/authenticates the user

<Aside type="tip">
	You can still disable users locally in EmDash. A disabled user will be rejected even if Access
	allows them through.
</Aside>

### 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

#### "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"

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
