
Cross-App Authentication on AT Protocol: How Roomy and OpenMeet Share Identity
The Problem
You’re using Roomy to collaborate with your community. Roomy’s team designed a calendar view that pulls in AT Protocol events — someone created an event on OpenMeet and it shows up right there. You click the event, and now you’re on OpenMeet, staring at a login page. You already proved who you are when you logged into Roomy. Why do you have to do it again?

This is the classic federated identity problem. How do you carry your authenticated session from one app to another without a centralized identity provider like “Sign in with Google”?
On AT Protocol, the answer is service auth. We recently shipped a working implementation between Roomy and OpenMeet that makes this seamless.
Experimental: proceed with caution. We’re using AT Protocol’s service auth JWTs for third-party cross-app login. This is not an officially documented pattern and has not been vetted by the atproto community. This writeup is to discuss it. The cryptographic primitives work, but we may be pushing the spec beyond its intended use. This approach could break or be explicitly discouraged in future revisions. If you implement this, read the limitations section first and understand the risks.
How AT Protocol Service Auth Works
Every AT Protocol user has a Personal Data Server (PDS) that holds their data and signing keys. The PDS can issue short-lived JWTs on behalf of the user, signed with the same cryptographic key that signs their repository updates.
The XRPC spec defines the JWT format:
| Claim | Required | Purpose |
|---|---|---|
iss | Yes | The user’s DID (decentralized identifier). Can include a service suffix like #atproto_labeler |
aud | Yes | The DID of the service being called |
exp | Yes | Expiration timestamp (typically <60 seconds) |
iat | Yes | Token creation time |
jti | Yes | Unique random nonce for replay prevention |
lxm | Optional | Lexicon method being invoked. Scopes what the token can do |
The JWT is signed with the account’s atproto signing key — the same cryptographic key that signs all of the user’s repository data. Anyone can verify the signature by resolving the user’s DID document and checking the public key listed there.
The important thing here is that the PDS is the root of the user’s authority. When Roomy needs to prove to OpenMeet that a user is who they claim to be, it asks the user’s PDS to sign a JWT addressed to OpenMeet. OpenMeet verifies that signature by resolving the user’s DID document and checking the signing key. No shared secrets between the apps. No centralized auth server. No OAuth dance between Roomy and OpenMeet.
How Bluesky uses this today
Bluesky uses service auth internally for its own services. You can see it in any Bluesky-compatible OAuth client’s scope list: scopes like rpc:chat.bsky.convo.getConvo?aud=did:web:api.bsky.chat#bsky_chat authorize the PDS to sign tokens addressed to the chat service. The Bluesky service auth docs describe the mechanism.
But there’s a caveat. As discussed in atproto#3424, service auth’s scope system is still being formalized. The Bluesky team has stated that “PDS instances should not accept service auth from any other service” for PDS-to-PDS operations. Right now the mechanism is limited to specific use cases like account operations and video blob uploads.
What we’re doing is different: using service auth for third-party cross-app authentication, where neither Roomy nor OpenMeet is the PDS. The PDS signs the token, but a separate application server consumes it. This works because the JWT verification is generic; any service can resolve a DID document and check a signature. But we should be honest: this is a gray area. The spec doesn’t explicitly prohibit what we’re doing (we’re not a PDS accepting tokens from another PDS, which is discouraged). But it also wasn’t designed for this. We’re relying on the generality of the crypto, not on a formal blessing from the spec authors.
The Implementation
1. OAuth Scope Declaration (Roomy side)
Before Roomy can request service auth tokens for OpenMeet, the user needs to grant that permission during OAuth login. Roomy declares the scope in its OAuth client metadata:
rpc:net.openmeet.auth?aud=*
This follows AT Protocol’s permission format: rpc:<lexicon-method>?aud=<service-did>. The aud=* wildcard means the user consents to Roomy requesting net.openmeet.auth tokens addressed to any service DID. At token-signing time, Roomy passes a specific audience (e.g., did:web:api.openmeet.net), and OpenMeet’s verifier checks that aud matches its own DID. We use the wildcard because dev, staging, and prod each have different DIDs. For production you could pin to a specific DID for tighter security. More on that below.
This scope gets consented to alongside Roomy’s other service auth scopes (Bluesky chat, profile lookups, etc.) during OAuth login. If the scope is missing, the PDS refuses to sign tokens for OpenMeet. We learned this the hard way: the scope was in the dev config but missing from the production build script. Users on roomy.space got invalid_scope errors until we fixed it (roomy#591).
Another thing we ran into: when you add a new scope, existing users don’t have it. Their session was granted before the scope existed. Rather than forcing everyone to log out and back in, Roomy now detects stale scopes at session restore. It compares the granted scopes against the current required scopes, and if anything’s missing, silently redirects through the PDS authorization flow. The user’s DID gets passed to oauth.authorize(), so the PDS knows who’s re-authorizing and doesn’t prompt for credentials again (roomy#598).
2. Token Exchange (Roomy → OpenMeet API)
When a user opens Roomy’s calendar page, Roomy silently connects to OpenMeet in the background:
// Roomy asks the user's PDS for a service auth token
const serviceDid = `did:web:${new URL(apiUrl).hostname}`;
const token = await peer.getServiceAuthToken(serviceDid, "net.openmeet.auth");
// Send it to OpenMeet's service auth endpoint
const response = await fetch(`${apiUrl}/api/v1/auth/atproto/service-auth`, {
method: "POST",
headers: { "Content-Type": "application/json", "x-tenant-id": tenantId },
body: JSON.stringify({ token }),
});
// OpenMeet returns standard JWT access/refresh tokens
const { token: accessToken, refreshToken } = await response.json();
No redirect, no popup, no login form. Roomy’s JavaScript client now holds OpenMeet API tokens and can make authenticated calls (fetching events, checking RSVP status) on behalf of the user.
But these tokens live in Roomy’s JavaScript context. When the user clicks a calendar event and Roomy opens the OpenMeet website in a new browser tab, that tab has no tokens. The user would see a login page. That’s where magic login links come in.
3. Verification (OpenMeet API)
On the OpenMeet side, the verification steps are:
- Decode the JWT, validate
iss,aud,exp - Check
audmatches our service DID (did:web:api.openmeet.net) - Check
lxmisnet.openmeet.auth. The spec makeslxmoptional, but we require it; tokens without method binding are too broad for our use case - Check expiration: reject anything older than 60s or with expiry more than 5 minutes out
- Resolve the DID document: fetch the user’s signing key from PLC directory. This is one network call per auth exchange; after that, the user has session tokens and subsequent requests skip DID resolution
- Verify the signature using
@atproto/crypto’sverifySignature() - Replay protection: SHA-256 hash the token, store it in Redis with a TTL. Reject any token we’ve seen before. The spec’s
jtinonce serves a similar purpose; our Redis check is belt-and-suspenders - Find or create the user: look up the DID in our identity table. Auto-create a minimal account if they’re new
If everything checks out, OpenMeet issues standard JWT access and refresh tokens.
The Magic Login Link: Crossing the Browser Boundary
Service auth gives Roomy’s JavaScript client API-level access to OpenMeet. But when the user clicks a calendar event and opens the OpenMeet website in a new tab, the browser needs its own session.
We solved this with one-time login links:
- Roomy (already holding OpenMeet API tokens) calls
POST /api/v1/auth/create-login-linkwith the target path (e.g.,/events/my-event-slug) - OpenMeet generates a random 64-character hex code, stores it in Redis with a 60-second TTL tied to the user ID and tenant, and returns a URL:
https://platform.openmeet.net/auth/token-login?code=a1b2c3...&redirect=%2Fevents%2Fmy-event - Roomy opens this URL in a new browser tab
- OpenMeet’s platform exchanges the code for JWT tokens (
POST /api/v1/auth/exchange-login-link) and redirects to the event page
The code is single-use (Redis GETDEL atomically retrieves and deletes), expires in 60 seconds, and rejects open redirects (the path must be relative, no ://, no protocol-relative URLs).
One UX trick worth mentioning: Roomy opens the tab with window.open("about:blank") immediately on click to preserve the user gesture and avoid popup blockers, then sets w.location.href to the login link URL once the API responds.
Security: What’s Good and What’s Not
The things we feel good about:
- No shared secrets between Roomy and OpenMeet. Authentication is cryptographically verified against the DID document.
- Short-lived tokens: service auth JWTs expire in <60 seconds, login link codes in 60 seconds.
- Single-use tokens: both service auth tokens and login link codes are consumed on first use via Redis-backed replay protection.
- User-consented scopes: the user explicitly grants the
rpc:net.openmeet.authscope during OAuth login. - Audience binding at verification time: OpenMeet checks that
audmatches its own service DID, so a token addressed todid:web:api.openmeet.netis useless at any other service. - Method binding: the
lxmclaim restricts what the token can do (justnet.openmeet.auth, not arbitrary API calls).
Limitations and warnings
A short-lived token creates a long-lived session. This is the biggest thing to understand. The service auth JWT is single-use and expires in 60 seconds, but once exchanged it becomes a full OpenMeet session with a JWT and refresh token that lasts much longer. If someone manages to replay the token within that 60-second window, they get a persistent session. The short TTL and single-use properties shrink the attack window, but they don’t shrink the blast radius. The same applies to login link codes.
PDS proxying as an alternative for API calls. For API-level access (Roomy fetching events from OpenMeet), XRPC proxying through the PDS could avoid this problem entirely. Instead of exchanging one service auth token for a long-lived session, each API call would be individually proxied through the user’s PDS with its own short-lived JWT — this is how Bluesky’s own services work, with the PDS creating a new token for each call to the appview. The tradeoff is that the receiving service’s endpoints need to be XRPC-based (lexicon-defined), and each call pays the cost of DID resolution. The login link problem remains either way — getting a browser session still requires some form of token exchange.
This is authentication, not authorization. Service auth proves the user is who they claim to be. It says nothing about what Roomy is allowed to do on their behalf at OpenMeet. Once the user’s identity is verified, OpenMeet’s own authorization system takes over. They get a normal OpenMeet account with the same permissions as any other user. Roomy doesn’t get any special access or elevated privileges; it just helped the user skip the login form. If you needed something like “Roomy can RSVP for me but can’t delete my events,” you’d need a cross-app authorization layer on top, which the protocol doesn’t provide yet.
The login link is a bearer token. Anyone who gets the URL within the 60-second window can use it. HTTPS keeps it off the wire, and single-use means it can’t be replayed, but don’t log these URLs.
PDS availability matters. If the user’s PDS is down, Roomy can’t get a service auth token. No offline fallback. That’s the tradeoff with federation.
The aud=* scope wildcard. In Roomy’s OAuth scope, aud=* means the user consents to Roomy requesting net.openmeet.auth tokens for any service DID. OpenMeet still validates aud at verification time, so a token addressed to our DID is useless elsewhere. But a compromised version of Roomy could request tokens addressed to other services that also accept net.openmeet.auth. In practice, net.openmeet.auth is a method name only OpenMeet uses, so the risk is low. For maximum safety, pin to a specific DID: rpc:net.openmeet.auth?aud=did:web:api.openmeet.net.
Auto-account creation. OpenMeet auto-creates a minimal user when a new DID shows up via service auth. That’s the right UX for us (frictionless onboarding), but other apps might want to gate access or collect more profile info first.
The spec is still evolving. Service auth’s scope system is deliberately under-specified. There’s no formal delegation or fine-grained capability system in the protocol yet. What exists today (lxm and aud binding) covers our use case. If you need something more nuanced, like delegated write access across apps, you’ll be building beyond what the spec defines.
The Bigger Picture
The user’s PDS is their identity authority. Unlike “Sign in with Google,” though, the PDS is part of a federated network. You can choose your PDS provider or self-host. The trust runs from the user’s cryptographic keys through their PDS to the receiving app, not through a corporate gatekeeper. That said, the user’s PDS is a single point of dependency for their auth. It’s not centralized in the “one company controls everyone” sense, but it is centralized in the “this one server needs to be up” sense.
What we built is a proof of concept. A user logs into one AT Protocol app and silently authenticates to another, as long as the OAuth scope was granted. Bluesky already does this internally for its own services. We’re the first third-party apps (that we know of) to use it for cross-app login. The rpc permission scope system and JWT verification machinery are general-purpose enough that it just works, even though the spec authors haven’t formally documented this use case.
If you want to try this for your own AT Protocol app:
- Pick a lexicon method name for your auth endpoint (e.g.,
net.openmeet.auth) - Publish your service DID (e.g.,
did:web:api.yourdomain.com) - Have the calling app add
rpc:your.auth.method?aud=<your-did>to its OAuth scope - Implement JWT verification against the user’s DID document
- Issue your own session tokens after verification
The code is open source: OpenMeet API PR #527, Roomy PR #590.
Feedback welcome: @tompscanlan.bsky.social
Further reading
- XRPC Specification, Inter-Service Authentication: the canonical spec for service auth JWTs
- AT Protocol Permissions: the
rpcscope format and OAuth scope declarations - Service Auth | Bluesky Docs: Bluesky’s guide to service auth
- atproto#3424, Service Auth Token Specification: discussion on the current state and future direction of service auth
- atproto#2687, Service auth token iteration: method binding and nonces

