---
title: AT Protocol OAuth 🤝 FastMCP
sidebarTitle: AT Protocol
description: Let people sign in to your FastMCP server with their AT Protocol handle
icon: at
---
import { VersionBadge } from "/snippets/version-badge.mdx"
`ATProtoProvider` is experimental and lives in `fastmcp.experimental`. Its interface may change or be removed in any release, so pin your FastMCP version when you use it.
`ATProtoProvider` lets people sign in to your FastMCP server with their [AT Protocol](https://atproto.com) account, wherever it is hosted. You don't register an app anywhere: AT Protocol clients identify themselves by a URL they serve, and FastMCP serves it for you.
MCP clients see a standard OAuth server with dynamic client registration, inherited from the [OAuth Proxy](/servers/auth/oauth-proxy). After the consent screen, users enter their handle or DID and approve the sign-in with their account's authorization server. FastMCP keeps only the verified DID, revokes the grant right away, and issues its own tokens. The AT Protocol side is handled by [`atproto-oauth`](https://pypi.org/project/atproto-oauth/), installed with the `atproto` extra:
```bash
pip install "fastmcp[atproto]"
```
## Configuration
```python server.py
from fastmcp import FastMCP
from fastmcp.experimental.auth.atproto import ATProtoProvider
auth = ATProtoProvider(
base_url="https://mcp.example.com",
jwt_signing_key="a-long-random-secret",
allowed_dids=["did:plc:ewvi7nxzyoun6zhxrhs64oiz"],
)
mcp = FastMCP("My Server", auth=auth)
```
- `base_url` is the public URL of your server. FastMCP serves the AT Protocol client metadata at `{base_url}/oauth-client-metadata.json` and receives the sign-in at `{base_url}/auth/callback`.
- `jwt_signing_key` signs the tokens FastMCP issues. Keep it stable, or every session ends when the server restarts.
- `allowed_dids` lists the accounts that may sign in. Leave it out to allow any account. Use DIDs rather than handles, because a handle can move to a different account. To find an account's DID, look up the `_atproto` TXT record of its handle (for example, `dig TXT _atproto.alice.example.com`) or fetch `https:///.well-known/atproto-did`.
The allowlist is checked before the user is sent to their authorization server, again when they return, and on every request. Removing a DID takes effect on that account's next request.
With exactly one allowed DID, there is no handle to ask for: after the consent screen, the browser goes straight to that account's authorization server. The handle page appears only to show an error.
## Local development
AT Protocol treats `http://127.0.0.1` as a loopback client that needs no hosted metadata, so local development works without a tunnel:
```python
auth = ATProtoProvider(
base_url="http://127.0.0.1:8000",
jwt_signing_key="dev-secret",
)
```
Use `127.0.0.1` rather than `localhost`: authorization servers reject `localhost` redirect URIs, so the provider refuses it.
## Reading the signed-in account
The access token carries the account's DID as its subject and its handle as a claim:
```python
from fastmcp.server.dependencies import get_access_token
@mcp.tool
def whoami() -> str:
token = get_access_token()
return f"{token.claims['handle']} ({token.subject})"
```
The `handle` claim is set only when the account's handle resolves back to its DID; otherwise it is `None`.
## Sessions
`identity_token_expiry_seconds` (default one hour) sets how long each access token lasts, and `session_expiry_seconds` (default 30 days) sets how long a sign-in lasts before the user must sign in again. Refreshing re-checks the allowlist but doesn't contact the account's authorization server.
## Login page
The login page suggests accounts as the user types, using `typeahead_url`. Set it to `None` to turn suggestions off.
Pending sign-ins are kept in memory, so run a single server process per provider.