---
title: Authentication
description: Passkey-first authentication for your EmDash site.
---

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.

For Cloudflare deployments, you can optionally use [Cloudflare Access](#cloudflare-access) as an alternative authentication provider.

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

## OAuth Login

EmDash supports OAuth login with GitHub and Google when configured. Users can link their accounts after initial passkey setup.

See the [Configuration guide](/reference/configuration#oauth) for setup instructions.

## User Roles

EmDash uses role-based access control with five levels:

| Role        | Level | Description                     |
| ----------- | ----- | ------------------------------- |
| Subscriber  | 10    | View-only access                |
| Contributor | 20    | Create content (needs approval) |
| 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.

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