diff --git a/.changeset/itchy-walls-smell.md b/.changeset/itchy-walls-smell.md
new file mode 100644
index 000000000..865fa0888
--- /dev/null
+++ b/.changeset/itchy-walls-smell.md
@@ -0,0 +1,6 @@
+---
+"@emdash-cms/admin": minor
+"emdash": minor
+---
+
+Adds AT Protocol OAuth login for signing into the CMS with an Atmosphere handle
diff --git a/docs/src/content/docs/guides/authentication.mdx b/docs/src/content/docs/guides/authentication.mdx
index 12b77e4fe..50255c236 100644
--- a/docs/src/content/docs/guides/authentication.mdx
+++ b/docs/src/content/docs/guides/authentication.mdx
@@ -93,6 +93,64 @@ EmDash supports OAuth login with GitHub and Google when configured. Users can li
See the [Configuration guide](/reference/configuration#oauth) for setup instructions.
+## ATProto Login
+
+EmDash supports login via the [AT Protocol](https://atproto.com/) (the protocol behind Bluesky). Users can sign in with any AT Protocol handle — whether on `bsky.social` or a self-hosted PDS.
+
+### How It Works
+
+
+
+1. Enable ATProto in your config:
+
+ ```js title="astro.config.mjs"
+ import emdash from "emdash/astro";
+
+ emdash({
+ atproto: true,
+ })
+ ```
+
+2. The login page shows a "Sign in via the Atmosphere" section with a handle input
+
+3. The user enters their handle (e.g., `alice.bsky.social`) and clicks **Sign in**
+
+4. They're redirected to their PDS to authorize the login
+
+5. After authorization, they're redirected back to EmDash
+
+
+
+### Email Verification
+
+ATProto doesn't provide email addresses during OAuth. After authorizing on their PDS, users are asked to enter an email address and verify it via a confirmation link. This email is used for:
+
+- Matching with existing EmDash accounts
+- Checking against [allowed domains](#self-signup) for self-signup
+- Account communication (invites, magic links)
+
+
+
+### Access Control
+
+ATProto login follows the same access rules as GitHub/Google OAuth:
+
+- **Existing users** — If the email matches an existing user, the ATProto identity is linked to that account
+- **Allowed domains** — New users can sign up if their email domain is in the allowed domains list (with the domain's default role)
+- **No match** — Users without a matching account or allowed domain are rejected
+
+
+
+### Identity Storage
+
+When a user signs in via ATProto, their DID (Decentralized Identifier) is stored on their user profile. This is separate from the existing [ATProto plugin](/plugins/overview) which handles content syndication to Bluesky using app passwords.
+
## User Roles
EmDash uses role-based access control with five levels:
diff --git a/docs/src/content/docs/reference/configuration.mdx b/docs/src/content/docs/reference/configuration.mdx
index e1d537604..19a3ad0e9 100644
--- a/docs/src/content/docs/reference/configuration.mdx
+++ b/docs/src/content/docs/reference/configuration.mdx
@@ -173,6 +173,22 @@ oauth: {
}
```
+### `atproto`
+
+**Optional.** Enable AT Protocol OAuth login. When `true`, the login page shows a handle input for signing in with a Bluesky handle or any PDS-hosted identity.
+
+```js
+emdash({
+ atproto: true,
+})
+```
+
+ATProto login is additive — it works alongside passkeys, OAuth, and magic links. It does not replace or disable any existing auth method.
+
+Users who sign in via ATProto must verify their email address before their account is created. Access is gated by the same [allowed domains](#authselfsignup) rules as other auth methods.
+
+See the [Authentication guide](/guides/authentication#atproto-login) for details.
+
#### `auth.session`
Session configuration.
diff --git a/packages/admin/src/components/LoginPage.tsx b/packages/admin/src/components/LoginPage.tsx
index 6d028a71c..8379f599a 100644
--- a/packages/admin/src/components/LoginPage.tsx
+++ b/packages/admin/src/components/LoginPage.tsx
@@ -206,6 +206,71 @@ function MagicLinkForm({ onBack }: MagicLinkFormProps) {
);
}
+// ============================================================================
+// ATProto Login Form
+// ============================================================================
+
+function AtprotoLoginForm() {
+ const [handle, setHandle] = React.useState("");
+ const [isLoading, setIsLoading] = React.useState(false);
+ const [error, setError] = React.useState(null);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError(null);
+ setIsLoading(true);
+
+ try {
+ const response = await apiFetch("/_emdash/api/auth/atproto/login", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ handle: handle.trim() }),
+ });
+
+ if (!response.ok) {
+ const body: { error?: { message?: string } } = await response.json().catch(() => ({}));
+ throw new Error(body?.error?.message || "Failed to start login");
+ }
+
+ const result: { data?: { redirectUrl?: string } } = await response.json();
+ if (result.data?.redirectUrl) {
+ window.location.href = result.data.redirectUrl;
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to start login");
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+ );
+}
+
// ============================================================================
// Main Component
// ============================================================================
@@ -321,6 +386,24 @@ export function LoginPage({ redirectUrl = "/_emdash/admin" }: LoginPageProps) {
))}
+ {/* ATProto Login */}
+ {manifest?.atprotoEnabled && (
+ <>
+