const CSS = ` :root { color-scheme: light; --ink: #1d1d1f; --muted: #6e6e73; --faint: #86868b; --bg: #ffffff; --band: #f5f5f7; --line: rgba(0, 0, 0, 0.08); --accent: #0066cc; --focus: #0071e3; --note: #e8f2fc; --note-ink: #003d73; --important: #fff4d6; --important-ink: #6b4f00; --sans: "SF Pro Text", "SF Pro Display", -apple-system, BlinkMacSystemFont, "Helvetica Neue", Helvetica, Arial, sans-serif; --mono: "SF Mono", ui-monospace, Menlo, Monaco, "Courier New", monospace; } * { box-sizing: border-box; } html { scroll-behavior: smooth; } html, body { margin: 0; padding: 0; } body { font-family: var(--sans); background: var(--bg); color: var(--ink); line-height: 1.47; -webkit-font-smoothing: antialiased; } a { color: var(--accent); text-decoration: none; } a:hover { text-decoration: underline; text-underline-offset: 0.18em; } :focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; } .skip { position: absolute; left: 0.75rem; top: -3rem; z-index: 20; background: #fff; padding: 0.5rem 0.75rem; border-radius: 0.5rem; } .skip:focus { top: 0.75rem; } .nav { position: sticky; top: 0; z-index: 10; display: flex; align-items: center; justify-content: space-between; gap: 1rem; min-height: 3.25rem; padding: 0 1.25rem; background: rgba(255, 255, 255, 0.72); backdrop-filter: saturate(180%) blur(20px); -webkit-backdrop-filter: saturate(180%) blur(20px); border-bottom: 1px solid var(--line); } .wordmark { font-size: 1.05rem; font-weight: 600; letter-spacing: -0.03em; color: var(--ink); text-decoration: none; } .wordmark:hover { text-decoration: none; } .nav-links { display: flex; gap: 1.4rem; font-size: 0.8rem; color: var(--muted); } .nav-links a { color: inherit; } .nav-links a[aria-current="page"] { color: var(--ink); font-weight: 600; } .shell { display: grid; grid-template-columns: 1fr; width: min(72rem, calc(100% - 2.5rem)); margin: 0 auto; gap: 2rem; padding: 2rem 0 4.5rem; } .toc { display: none; } .toc h2 { margin: 0 0 0.75rem; font-size: 0.72rem; letter-spacing: 0.08em; text-transform: uppercase; color: var(--faint); border: 0; padding: 0; } .toc ol { margin: 0; padding: 0; list-style: none; } .toc a { display: block; padding: 0.28rem 0; color: var(--muted); font-size: 0.84rem; } .toc a:hover { color: var(--accent); } article { min-width: 0; } .eyebrow { margin: 0 0 0.65rem; font-size: 0.75rem; font-weight: 600; letter-spacing: 0.12em; text-transform: uppercase; color: var(--faint); } h1 { margin: 0 0 1rem; font-size: clamp(2rem, 4vw, 2.75rem); font-weight: 600; letter-spacing: -0.035em; line-height: 1.1; } .lede { margin: 0 0 2rem; max-width: 42rem; font-size: 1.15rem; color: var(--muted); } h2 { margin: 2.75rem 0 0.85rem; padding-top: 1.25rem; border-top: 1px solid var(--line); font-size: 1.55rem; letter-spacing: -0.03em; scroll-margin-top: 4rem; } h3 { margin: 1.75rem 0 0.55rem; font-size: 1.15rem; letter-spacing: -0.02em; scroll-margin-top: 4rem; } p, li { font-size: 1.02rem; color: var(--ink); } p { margin: 0 0 0.9rem; } .muted { color: var(--muted); } ol.steps { margin: 0 0 1.25rem; padding-left: 1.35rem; } ol.steps > li { margin: 0.45rem 0; } ul { margin: 0 0 1.25rem; padding-left: 1.25rem; } code, .mono { font-family: var(--mono); font-size: 0.9em; } p code, li code { background: var(--band); padding: 0.1em 0.35em; border-radius: 0.3em; } .listing { margin: 1.1rem 0 1.4rem; background: var(--band); border-radius: 0.7rem; overflow: hidden; } .listing figcaption { padding: 0.55rem 0.95rem 0; font-size: 0.72rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--faint); } .listing pre { margin: 0; padding: 0.7rem 0.95rem 1rem; overflow-x: auto; } .listing code { font-size: 0.82rem; line-height: 1.5; color: var(--ink); white-space: pre; } .callout { margin: 1.15rem 0 1.4rem; padding: 0.9rem 1rem; border-radius: 0.7rem; font-size: 0.95rem; } .callout strong { display: block; margin-bottom: 0.25rem; } .callout p { margin: 0; font-size: inherit; } .callout.note { background: var(--note); color: var(--note-ink); } .callout.important { background: var(--important); color: var(--important-ink); } table { width: 100%; border-collapse: collapse; margin: 0.75rem 0 1.4rem; font-size: 0.92rem; } th, td { text-align: left; padding: 0.55rem 0.65rem; border-bottom: 1px solid var(--line); vertical-align: top; } th { color: var(--muted); font-weight: 600; font-size: 0.75rem; letter-spacing: 0.04em; text-transform: uppercase; } td code { background: transparent; padding: 0; } footer { padding: 1.75rem 0 2.5rem; border-top: 1px solid var(--line); color: var(--faint); font-size: 0.8rem; } footer .wrap { width: min(72rem, calc(100% - 2.5rem)); margin: 0 auto; display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap; } footer a { color: inherit; } @media (min-width: 980px) { .nav { padding: 0 2rem; } .shell { grid-template-columns: 14rem minmax(0, 46rem); gap: 3.5rem; padding-top: 2.75rem; } .toc { display: block; position: sticky; top: 4.5rem; align-self: start; } } @media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } } `; const FAVICON = `data:image/svg+xml,${encodeURIComponent( ``, )}`; function escapeHtml(value: string): string { return value .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """); } function listing(caption: string, code: string): string { return `
${escapeHtml(caption)}
${escapeHtml(code)}
`; } function callout(kind: "note" | "important", title: string, body: string): string { return ``; } export function developersPage(): string { return ` Integrating loggedin.fyi — Documentation

Documentation

Integrating loggedin.fyi

Use loggedin.fyi to fetch the Atmosphere accounts a user has recently used in their browser, from other Atmosphere apps participating with loggedin.fyi, and allow the user to quick-start the same OAuth login your app already implements. This is not single sign-on, and it is not a new identity provider.

loggedin.fyi stores a short-lived, record that a DID was used on a participating AT Proto app. Your AT Proto app can call to loggedin.fyi to retrieve that list. Then you can choose how to display that list of DIDs, and offer them as login hints to the user, showing them which accounts they have already logged into, across the Atmosphere (of apps that are participating with loggedin.fyi). Selecting an account does not create a session with loggedin.fyi. Your AT Proto app still must complete a normal AT Proto OAuth flow with the user’s PDS.

In a browser, the session key is an HttpOnly cookie on the loggedin.fyi origin (loggedin_session, seven-day TTL). Cross-site embeds use Secure; SameSite=None. A grant of storage access is scoped to the pair (your AT Proto app, loggedin.fyi). Native iOS and Android clients reach that same cookie through the system browser once, then carry a signed X-Loggedin-Token that names the same session.

${callout( "note", "Note", "Your app decides when to call loggedin.fyi to confirm that the user has logged in to your app. loggedin.fyi does not opt people in on their behalf. You can choose to do this automatically or ask the user first. It's strongly recommended to prompt the user when they first sign in to your app. Use that same dialog to explain if your app will periodically call to loggedin.fyi to confirm that the user is still logged in to your app.", )}

Getting Started

To add loggedin.fyi to your client, complete the following steps:

  1. Publish your existing OAuth client-metadata.json document at https://your-app.example/oauth-client-metadata.json on your AT Proto app origin. If you have an AT Proto app already, you likely have this document; place it at that conventional path (or ensure it is reachable there).
  2. Load the loggedin.fyi JavaScript interface and embed /embed with the storage-access permission.
  3. Render the returned accounts and, when the user selects one, start your existing OAuth login with that DID.
  4. After sign-in, update loggedin.fyi with the DID of the active user, so other participating apps on this device can offer it. On logout, remove the hint.
  5. Optionally, you can also periodically call loggedin.fyi to confirm that the user is still logged in to your app, and update the list of accounts on loggedin.fyi.
  6. On iOS or Android, use the native SDK instead of the embed. Those clients bootstrap through the browser’s shared cookie jar once, then send X-Loggedin-Token.

The sections below expand each step. Replace https://your-app.example with your production origin, and set LOGGEDIN_ORIGIN to the loggedin.fyi host you are targeting.

Publishing Client Metadata

Any HTTPS origin that publishes valid OAuth client metadata at /oauth-client-metadata.json may participate. There is no static allowlist and no deploy gate. loggedin.fyi fetches that document when your app embeds, enrolls, saves, or heartbeats.

Serve the document at that URL with an application/json content type and status 200 without a redirect. loggedin.fyi requires all of the following:

When validation succeeds, client_name and logo_uri appear as labels in the chooser and when clients call GET /origins. Localhost remains available even if metadata cannot be fetched, so you can develop against a self-signed certificate.

${listing( "Listing 1", `{ "client_id": "https://your-app.example/oauth-client-metadata.json", "client_name": "Your App", "client_uri": "https://your-app.example", "logo_uri": "https://your-app.example/logo.png", "redirect_uris": [ "https://your-app.example/oauth/callback" ] }`, )} ${callout( "important", "Important", "A storage-access grant collected on one origin does not apply to another. Each participating app must obtain its own grant.", )}

Embedding the Interface

Load the JavaScript interface from /example-renderer.js. The script defines window.Loggedin. Attach the message listener before you set the iframe src, and include the storage-access permission on the iframe.

${listing( "Listing 2", `const LOGGEDIN_ORIGIN = "https://loggedin.fyi"; async function loadLoggedin() { if (window.Loggedin) return window.Loggedin; await new Promise((resolve, reject) => { const script = document.createElement("script"); script.src = LOGGEDIN_ORIGIN + "/example-renderer.js"; script.onload = resolve; script.onerror = () => reject(new Error("loggedin.fyi is unreachable")); document.head.appendChild(script); }); return window.Loggedin; } const iframe = document.querySelector("#loggedin-embed"); const Loggedin = await loadLoggedin(); Loggedin.mountEmbed(iframe, LOGGEDIN_ORIGIN + "/embed", { onReady() {}, onNeedsAccess() {}, onPopup() {}, onAccounts(accounts) { Loggedin.renderAccountList(list, accounts, { onPick(did, handle) { startOauth(handle.startsWith("did:") ? did : handle); }, }); }, onPicked(did) { startOauth(did); }, });`, )} ${callout( "important", "Important", "Call Loggedin.mountEmbed (or prepareEmbed plus listenToEmbed before assigning src). If you set src first, the embed can post accounts before your listener exists.", )}

Displaying Hinted Accounts

When storage access is available, the embed posts { type: "accounts", accounts }. Each account is a DID and the origins that loggedin.fyi has recorded for it:

${listing( "Listing 3", `{ "type": "accounts", "accounts": [ { "did": "did:plc:example", "origins": ["https://other-app.example"] } ] }`, )}

Use Loggedin.renderAccountList to draw the list, or render it yourself. Fetch GET /origins?origin=… for the origins that appear on those accounts if you want human-readable names.

${listing( "Listing 4", `const origins = [...new Set(accounts.flatMap((a) => a.origins || []))]; const qs = origins.map((o) => "origin=" + encodeURIComponent(o)).join("&"); const res = await fetch(LOGGEDIN_ORIGIN + "/origins?" + qs); const data = await res.json(); const labels = {}; for (const row of data.origins ?? []) labels[row.origin] = row.name; Loggedin.renderAccountList(listElement, accounts, { originLabels: labels, onPick(did, handle) { startOauth(handle.startsWith("@") ? handle.slice(1) : did); }, });`, )}

If the embed cannot use the cookie silently, it shows a Continue control and may post needs-access or popup. In the popup path, listen for picked and start OAuth with that DID.

Starting a Normal OAuth Login

Treat a selected DID as a login hint for the OAuth flow you already ship. Do not exchange anything with loggedin.fyi for a session.

${listing( "Listing 5", `function startOauth(identifier) { const url = new URL("/login", location.origin); url.searchParams.set("handle", identifier); location.href = url.toString(); }`, )} ${callout( "note", "Note", "Retrieved accounts are only a suggestion for the end user. The user still authenticates with their PDS. If they decline the retrieved accounts, your app should still offer a handle field as you would without loggedin.fyi.", )}

Heartbeating a Signed-In Session

After the person is signed in, your app may choose to periodically send that user's DID to loggedin.fyi so other participating apps on this device can offer it. This heartbeat occurs when the person is actively using your client — for example, on load, or after an intentional action. loggedin.fyi stores one DID and origin pair with a last-seen timestamp. Records expire after seven days.

${listing( "Listing 6", `Loggedin.heartbeat(iframe, session.did, LOGGEDIN_ORIGIN);`, )}

On Chrome, this posts { type: "heartbeat", did } to the embed, which calls POST /xrpc/fyi.loggedin.account.loggedIn. If the embed is in popup mode, heartbeat opens a first-party /save window instead. The origin query parameter must be your app, not loggedin.fyi.

You may also send people through Loggedin.enrollUrl after login if you want an explicit Save / Not now step on the loggedin.fyi origin.

${listing( "Listing 7", `location.href = Loggedin.enrollUrl( LOGGEDIN_ORIGIN, session.did, location.href, );`, )}

Removing a Hint on Logout

When the person signs out of your app, remove the hint for that DID so other clients stop offering it.

${listing( "Listing 8", `Loggedin.logout(iframe, session.did, LOGGEDIN_ORIGIN);`, )}

This posts { type: "logout", did } to the embed, or opens /save?action=logout when storage access is unavailable.

iOS and Android SDKs

A native app cannot hold the loggedin.fyi cookie, and neither platform will give you a device identifier to use instead — Apple’s Developer Program License Agreement forbids deriving one, and Android restricts hardware ids to privileged callers. Both platforms do, however, expose the browser’s shared cookie jar: ASWebAuthenticationSession on iOS and Custom Tabs on Android. That is the same loggedin_session cookie mobile Safari or Chrome already holds, so a native client and the web share one device session.

The SDKs make one visible trip through that sheet. The page is first-party on loggedin.fyi, so the cookie is attached; in exchange the worker mints a signed token carrying the same session id. Every call after that is a plain HTTPS request with X-Loggedin-Token and no browser.

${callout( "note", "Note", "The system shows a consent sheet naming loggedin.fyi before the cookie is shared. That is the native counterpart of the Storage Access prompt Safari and Firefox already show on the web, it is scoped to your app, and it cannot be suppressed.", )} ${listing( "Listing 9", `{ "client_id": "https://your-app.example/oauth-client-metadata.json", "client_name": "Your App", "client_uri": "https://your-app.example", "redirect_uris": [ "https://your-app.example/oauth/callback", "example.your-app://loggedin" ] }`, )}

The callback you pass to the SDK must appear verbatim in redirect_uris. Anything else is refused with 403, so a token can only ever be handed to a URL you published.

${listing( "Listing 10", `import Loggedin let client = try LoggedinClient( origin: URL(string: "https://your-app.example")!, callback: URL(string: "example.your-app://loggedin")!, presenter: SystemWebAuthPresenter { window } ) // Once. Shows the consent sheet, then the account chooser. if let hintedDid = try await client.bootstrap() { startOAuth(with: hintedDid) } // Afterwards — silent, no browser. let accounts = try await client.listAccounts() try await client.heartbeat(did: session.did)`, )} ${listing( "Listing 11", `val client = LoggedinClient( context, origin = "https://your-app.example", callback = "example.your-app://loggedin", ) client.bootstrap(activity) // Custom Tab // in your redirect Activity: val result = client.onBootstrapRedirect(intent.data!!) val accounts = client.listAccounts() client.heartbeat(session.did)`, )}

Pass the signed-in DID to bootstrap to show a “remember this account?” confirmation instead of the chooser. A picked DID is a login hint: start the same OAuth flow you already ship.

Tokens last at most seven days, matching the hint TTL. A 401 means the token expired or the person cleared their browser data — discard it and bootstrap again. The packages live in sdks/ios and sdks/android.

Storage Access Across Browsers

The embed and its XRPC calls use the unpartitioned loggedin_session cookie. Parent iframes must set allow="storage-access".

Do not call hasStorageAccess() inside the Continue click handler. That call can consume the user gesture. The embed requests storage access first; only then does it fall back to a popup.

A hidden iframe can reuse an existing grant on that site. It cannot collect a first grant. Show a visible Continue control when the embed posts needs-access.

Rate Limits

Open participation is gated by client metadata and edge rate limits, not a static list. Expect 429 with RateLimitExceeded when a client IP, app origin, or metadata fetch exceeds its budget. Heartbeats for the same DID and origin are coalesced for two minutes inside the session store. Each browser session keeps a bounded number of DID and origin pairs.

Endpoints

XRPC methods are unauthenticated. The cookie is the browser session key. Native SDKs send X-Loggedin-Token instead and never receive a session cookie; their writes are pinned to the origin inside the token. Write calls from the embed, enroll, and save pages also send the parent origin.

Lexicon / pageHTTP
fyi.loggedin.account.loggedInPOST /xrpc/fyi.loggedin.account.loggedIn
fyi.loggedin.account.logoutPOST /xrpc/fyi.loggedin.account.logout
fyi.loggedin.account.listGET /xrpc/fyi.loggedin.account.list
JavaScript interfaceGET /example-renderer.js
EmbedGET /embed
Origin labelsGET /origins?origin=…
First-party saveGET /save?did=&origin=&action=save|logout
EnrollGET /enroll?did=&return=
ChooserGET /choose?parent=
Native bootstrapGET /app/start?origin=&callback=&state=
Native sessionX-Loggedin-Token request header

GET /xrpc/fyi.loggedin.account.list returns { accounts: [{ did, origins }] }. See the Lightbox and Cratewalk demos for two complete clients that demonstrate how the hinted DIDs are used across different apps.

`; }