Install
Load the browser SDK
<script src="https://login.atmosphereaccount.com/atmosphere-login.js" defer></script>
<button
data-atmosphere-login
data-client-id="https://app.example.com/oauth/client-metadata.json"
data-return-uri="https://app.example.com/auth/atmosphere/selected"
data-app-name="Example App"
></button>
Open the picker
The SDK builds /login/select with client_id, return_uri, state, and optional scope.
Verify the selection
Your callback receives selection_token. Verify the ES256 signature and bind iss, aud, state, and return_uri.
Start AT Protocol OAuth
Use the selected handle or DID as the login hint. Your app still completes the AT Protocol OAuth profile with the user’s PDS or entryway.
Start with the working example
The reference app is a complete relying-app loop: redirect and popup buttons, picker handoff, callback verifier, app-owned AT Protocol OAuth start, app OAuth callback, and final signed-in app state.
/examples/atmosphere-login/appA copy-paste example app that uses the static SDK, returns to a verifier callback, starts app-owned AT Protocol OAuth, then shows the final signed-in app state. It includes redirect mode and popup mode so you can compare the browser behavior.
Click Continue with Atmosphere
The SDK stores a one-time state value, builds the picker URL, and sends the user to /login/select. Redirect mode navigates the page; popup mode keeps the app page open.
Choose an account
The picker returns selection_token, client_id, state, selected DID, handle, and issuer as query parameters.
Verify before doing anything else
The callback verifies signature, expiry, issuer, audience, state, return URI, and replay key before redirecting to the app OAuth start route. In popup mode, the popup completion page notifies the opener, and the opener still continues through that server-verified callback.
Finish as the app
The example app completes AT Protocol OAuth on its own callback and creates its own app session. Atmosphere never receives the app OAuth token.
Complete the ATProto OAuth handoff
Atmosphere Login only selects an account. After the selection token passes, your app should begin normal AT Protocol OAuth with the selected handle or DID as the login hint.
/examples/atmosphere-login/oauth/startStarts app-owned AT Protocol OAuth using the selected handle or DID from the verified selection token.
/examples/atmosphere-login/oauth/callbackExchanges the authorization code on the example app callback and mints an example-app session. Production apps should store their OAuth session in their own token store.
const verified = await verifyAtmosphereLoginCallback({
url: request.url,
publicJwk,
expectedIssuer: "https://login.atmosphereaccount.com",
expectedClientId,
expectedReturnUri,
expectedState,
replayStore,
});
if (!verified.ok) throw new Error(verified.error);
return redirect("/oauth/start?" + new URLSearchParams({
login_hint: verified.claims.handle || verified.claims.sub,
}));The example app consumes replay keys in the app database so the final callback rejects a reused selection token across app instances. Production apps should use their own durable store and keep each key until the token expires.
Do not send AT Protocol OAuth access tokens to Atmosphere. The relying app owns its OAuth session, refresh behavior, scopes, and logout semantics.
Account management stays with the host
Atmosphere Login gives apps a consistent account picker. It is intentionally not a replacement for the user's PDS account page.
Atmosphere can show picker history and apps that used the Atmosphere picker. The user's PDS host manages OAuth grants, connected apps, devices, sessions, passwords, keys, recovery, backups, deletion, and migration.
| Need | Where it belongs |
|---|---|
| Choose which account to use | Atmosphere hosted picker and signed selection token. |
| Grant an app repository access | The app's normal AT Protocol OAuth flow with the user's PDS or entryway. |
| Review or revoke all connected apps | The user's PDS-owned account page, usually exposed by the host. |
| Manage devices, passwords, recovery, backups, or migration | The user's account host. Atmosphere only routes to that destination when known. |
Try it locally
Generate a picker URL from the reference app or your registered app metadata, inspect redirect and popup button snippets, and paste a token to check pass/fail verification.
Test console
Generate a picker request
This uses the local reference callback so you can try the complete account-selection handoff in dev.
https://login.atmosphereaccount.com/login/select?client_id=https%3A%2F%2Flogin.atmosphereaccount.com%2Fexamples%2Fatmosphere-login%2Fclient-metadata.json&return_uri=https%3A%2F%2Flogin.atmosphereaccount.com%2Fexamples%2Fatmosphere-login%2Fcallback&state=7TYP1uOJFth98-TQuLMXVxlJ&scope=atproto{
"query": {
"selection_token": "eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0bW9zcGhlcmUtbG9naW4rand0...",
"client_id": "https://login.atmosphereaccount.com/examples/atmosphere-login/client-metadata.json",
"state": "7TYP1uOJFth98-TQuLMXVxlJ",
"did": "did:plc:example",
"handle": "alice.example",
"iss": "https://login.atmosphereaccount.com"
},
"selection_token_claims": {
"iss": "https://login.atmosphereaccount.com",
"aud": "https://login.atmosphereaccount.com/examples/atmosphere-login/client-metadata.json",
"sub": "did:plc:example",
"handle": "alice.example",
"return_uri": "https://login.atmosphereaccount.com/examples/atmosphere-login/callback",
"state": "7TYP1uOJFth98-TQuLMXVxlJ",
"scope": "atproto",
"pds_url": "https://bsky.social",
"app_name": "Example App",
"iat": 1767225600,
"exp": 1767225900,
"jti": "short-lived-replay-key"
}
}<button
data-atmosphere-login
data-client-id="https://login.atmosphereaccount.com/examples/atmosphere-login/client-metadata.json"
data-return-uri="https://login.atmosphereaccount.com/examples/atmosphere-login/callback"
data-scope="atproto"
data-mode="redirect"
data-app-name="Example App"
></button>
<script src="https://login.atmosphereaccount.com/atmosphere-login.js" defer></script>import { verifyAtmosphereLoginCallback } from "@atmosphere/login/server";
const result = await verifyAtmosphereLoginCallback({
url: request.url,
publicJwk,
expectedIssuer: "https://login.atmosphereaccount.com",
expectedClientId: "https://login.atmosphereaccount.com/examples/atmosphere-login/client-metadata.json",
expectedState: stateFromSession,
expectedReturnUri: "https://login.atmosphereaccount.com/examples/atmosphere-login/callback",
replayStore,
});
if (!result.ok) throw new Error(result.error);
const { sub: did, handle, pds_url } = result.claims;Verify a token
Paste selection_token
The console checks the token signature and the same audience, state, and return URI bindings your callback should enforce.
Open /dev/login-picker during local development to seed four fictional saved accounts with profile portraits and enter the normal same-tab redirect picker. The route and avatar mapping are unavailable in hosted production environments.
Register your app
Registering gives the picker a clear app identity and a return URI allow-list tied to your signed-in Atmosphere account.
/account/developer/appsSigned-in developer page for registering an app name, client ID, logo URL, homepage, allowed return URIs, and an optional verified preferred account host.
Sign in with the owner account
The current Atmosphere account becomes the owner of the app registration. That owner can update the name, logo, homepage, and return URI allow-list.
Use a stable client ID
For production, the client ID should be an HTTPS URL controlled by your app, usually your OAuth client metadata URL.
Add exact return URIs
Every production callback that receives selection_token must be listed exactly. Atmosphere strips URL fragments before matching.
Recommend a host you operate (optional)
If the same owner account has claimed a joinable account host, select it as the preferred host. The picker pins it first and labels it as recommended by the app, while keeping every other eligible host available.
Atmosphere verifies the host claim when the registration is saved and again when the picker opens. Apps cannot nominate an arbitrary host in the picker URL, and revoked, transferred, closed, or unconfigured hosts stop being recommended.
| State | Picker label | Meaning |
|---|---|---|
| development | Development app | Local-only development app. Use the ATProto http://localhost/ client ID shortcut with loopback IP return URIs while building. |
| unverified | Unverified app | Self-registered production app. The picker warns users before continuing. |
| trusted | Trusted | Atmosphere-reviewed app identity with a verified domain manifest and production return URI allow-list. |
| blocked | Blocked | This app cannot use the hosted picker. |
Trusted and local development apps show their state in the app card's status pill. Unverified apps receive an expanded warning before account selection, while blocked apps cannot open the picker.
Verify your app domain
Before an app can request Trusted status, its homepage origin must publish a small Atmosphere Login manifest. This proves the registered app identity is controlled by the same domain users see in the picker.
{
"version": "atmosphere.login.v0.1",
"apps": [
{
"client_id": "https://app.example.com/oauth/client-metadata.json",
"app_name": "Example App",
"homepage": "https://app.example.com",
"logo_uri": "https://app.example.com/icon.png",
"allowed_return_uris": [
"https://app.example.com/auth/atmosphere/selected"
]
}
]
}- Host this file at the HTTPS origin of your registered homepage.
- Use an
appsarray when one domain hosts more than one relying app. client_id,app_name,homepage,logo_uri, and every registeredallowed_return_urisentry must match the Atmosphere registration.- The developer app detail page fetches this file during Run checks and blocks Trusted review until it passes.
Allowed return URI rules
The return URI is where Atmosphere sends the short-lived account-selection token. Treat it like an OAuth redirect URI: exactness matters.
| Case | Rule |
|---|---|
| Registered production apps | The return_uri must exactly match one of the registered allowed return URIs, including scheme, host, port, path, and query. |
| URL fragments | Fragments are removed before matching and are never used as the delivery location for a selection token. |
| HTTPS | Production client IDs, homepages, logos, and return URIs must use HTTPS. |
| Local development | In development, the special http://localhost/ client ID can use loopback IP return URIs such as http://127.0.0.1:5173/callback for quick testing. |
Registered production apps use exact allowed return URIs. Atmosphere does not accept a whole origin such as https://app.example.com as a wildcard for every callback path.
Production checks
Registered apps get a health check panel before they ask users to trust the picker handoff.
/account/developer/apps/{clientId}Developer app detail page with production readiness, picker test URL generation, expected callback shape, and selection token verification.
| Check | What Atmosphere looks for |
|---|---|
| Client ID | Absolute URL, HTTPS in production, or the ATProto http://localhost/ shortcut in local development. |
| Homepage | A valid HTTPS homepage so people can identify the app. |
| Logo | A recognizable app mark, preferably served over HTTPS. |
| Return URIs | Exact callback URLs; no wildcard origins or fragment delivery. |
| Loopback/dev URLs | Any localhost, 127.0.0.1, or [::1] URL keeps the app in local-development readiness. |
| Domain alignment | Homepage and client ID domains should clearly belong to the same app identity. |
| Domain manifest | The app homepage origin serves /.well-known/atmosphere-login.json confirming the registered client ID, identity, and return URI allow-list. |
| Review status | Whether the app is development, unverified, requested, trusted, or blocked. |
- Local development only means loopback URLs are present.
- Needs production fixes means one or more production checks failed.
- Ready to request trusted review means production checks pass and the app can be submitted.
- Trusted means the app identity and return URI allow-list have been approved.
- Blocked means the picker will not continue for this app.
Trusted review requirements
Trusted review is about reducing ambiguity in the picker. It is not a security audit of the app's own OAuth implementation.
Finalize app identity
Use the production app name, homepage, logo, and client ID that users will recognize.
Remove local URLs
Replace the local client ID shortcut and every local homepage, logo, or return URI with HTTPS production or staging URLs.
Run the picker test
Generate a test picker URL on the app detail page, complete the handoff, and verify the returned callback URL or token.
Publish the domain manifest
Host /.well-known/atmosphere-login.json on the app homepage origin and make sure Run checks shows Domain manifest as passing.
Submit context
Explain what the app does, who maintains it, and why the registered domains should be shown as trusted.
Changing a trusted app's name, homepage, logo, or return URIs returns it to review so the picker does not imply stale trust.
How to move from local dev to production
Start with local dev
Use the docs console or your local app with the ATProto http://localhost/ client ID shortcut and a loopback IP return URI while developing.
Publish your app identity
Move to an HTTPS client ID, homepage, and logo URL on a domain your app controls.
Register production callbacks
Add every production selection callback to /account/developer/apps. Keep staging and production URLs separate.
Publish the domain manifest
Serve /.well-known/atmosphere-login.json from the homepage origin so Atmosphere can verify the app identity and callback allow-list.
Verify before OAuth
After the picker returns, verify the signed selection token and then start your own AT Protocol OAuth flow with the selected DID or handle as a hint.
Local:
client_id=http://localhost/?redirect_uri=http%3A%2F%2F127.0.0.1%3A5174%2Fauth%2Fatmosphere%2Fselected
return_uri=http://127.0.0.1:5174/auth/atmosphere/selected
Production:
client_id=https://app.example.com/oauth/client-metadata.json
return_uri=https://app.example.com/auth/atmosphere/selectedVerify the selection token
The token is an account-selection handoff, not an OAuth credential.
import {
fetchAtmosphereLoginPublicJwkForToken,
verifyAtmosphereLoginCallback,
} from "https://login.atmosphereaccount.com/atmosphere-login-server.js";
const callbackUrl = new URL(request.url);
const selectionToken = callbackUrl.searchParams.get("selection_token");
if (!selectionToken) throw new Error("Missing selection token");
const publicJwk = await fetchAtmosphereLoginPublicJwkForToken(
selectionToken,
"https://login.atmosphereaccount.com",
);
const result = await verifyAtmosphereLoginCallback({
url: callbackUrl,
publicJwk,
expectedIssuer: "https://login.atmosphereaccount.com",
expectedClientId: "https://app.example.com/oauth/client-metadata.json",
expectedState: stateFromSession,
expectedReturnUri: "https://app.example.com/auth/atmosphere/selected",
replayStore,
});
if (!result.ok) throw new Error(result.error);
const { sub: did, handle, pds_url } = result.claims;| Claim | Meaning | Required check |
|---|---|---|
| iss | Atmosphere Account origin | Must match the expected deployment. |
| aud | Requesting app client ID | Must equal your client_id. |
| sub | Selected account DID | Use as the durable account id. |
| handle | Selected account handle | Good for display and login hint. |
| pds_url | Known account host URL | Optional hint; still verify via OAuth. |
| state | App nonce | Must match the state you created. |
| return_uri | Selection callback | Must match the route receiving the token. |
| jti | Unique token id | Store until expiry and reject repeat use. |
Start ATProto OAuth
Once the selection token passes, use the verified account as a login hint for your own AT Protocol OAuth flow.
if (!result.ok) throw new Error(result.error);
const loginHint = result.claims.handle || result.claims.sub;
const oauthUrl = await yourAtprotoOAuthClient.authorizeUrl({
loginHint,
scope: "atproto",
});
return Response.redirect(oauthUrl);Atmosphere tells your app which account the user picked. Repository reads, writes, blobs, and app sessions still require your normal AT Protocol OAuth grant.
Security model
The selected DID/handle tells your app which account the user chose. It does not grant repository reads, writes, blobs, preferences, or account-management powers.
- Use a fresh
statevalue for every picker request. - Verify the JWT signature with
/oauth/jwks.json. - Require exact
aud,iss,state, andreturn_urimatches. - Reject replayed
jtivalues until the token expires. - Start your own AT Protocol OAuth flow after selection.
- Store OAuth refresh tokens server-side or according to the AT Protocol OAuth profile for your client type.
Production checklist
- Register the app at
/account/developer/appswith a stable HTTPS client ID. - Use exact allowed return URIs for production and staging callbacks.
- Show a recognizable app name, homepage, and HTTPS logo.
- Verify
iss,aud,state,return_uri, expiry, signature, and replay key server-side. - Start your own AT Protocol OAuth flow after selection; do not treat the selection token as an app session.
- Request trusted review when the app identity and production callbacks are ready.