rfc seperate service-auth signing key (crafted by ai, verified by humans)
vow-atproto-rfc.md edited
251 lines 11 kB view raw view code

ATProto Protocol Change Request: Separate Service-Auth Signing Key#

Summary#

ATProto should allow a DID document to declare a separate key for service-auth JWT signing, distinct from the commit signing key in verificationMethods.atproto. This would allow Personal Data Servers that do not hold the user's private signing key (keyless or "BYOK" PDSes) to issue valid service-auth tokens on the user's behalf without requiring the user's passkey to be present for every proxied request.


Background#

The current model#

ATProto defines a single verificationMethods.atproto slot in the DID document. This key is used for two distinct purposes:

  1. Commit signing — every mutation to the user's repository (createRecord, putRecord, deleteRecord, applyWrites) produces a signed commit. The signature is verified by relays and AppViews against this key.

  2. Service-auth JWT signing — when a PDS proxies a request to an AppView, feed generator, or labeller on the user's behalf, it presents a short-lived JWT signed by this same key. The receiver verifies it against the same DID doc slot.

In the reference PDS implementation these two purposes are satisfied trivially by the same key because the PDS holds the user's private key. The distinction is invisible.

The keyless PDS case#

A keyless (BYOK) PDS holds no user private key. The user's signing key lives exclusively in a passkey — a hardware-backed credential stored in the device's secure enclave (TPM, Secure Enclave, or a roaming authenticator such as a YubiKey). Every signature requires a user-presence gesture: a biometric, PIN confirmation, or physical key touch.

This model is attractive for user sovereignty: the PDS cannot forge commits, cannot impersonate the user to AppViews, and cannot migrate the account without the user's participation. The private key never leaves the authenticator.

It is currently impractical to implement under the ATProto spec for the reason described below.


The Problem#

Commit signing: acceptable UX#

Passkey-signed commits are user-initiated. The user taps "Post", the PDS builds the unsigned commit, forwards it to the browser signer over a WebSocket, and the passkey prompt appears. The user confirms, the signature is returned, and the commit is finalised. One gesture per intentional write. This is acceptable — it mirrors the mental model of "confirm this action".

Service-auth JWT signing: unacceptable UX#

Service-auth JWTs are invisible infrastructure. Every time the PDS proxies a request to an AppView, feed generator, or labeller on the user's behalf, it must present a fresh short-lived JWT signed by verificationMethods.atproto. These tokens are issued for:

  • Loading the home feed
  • Fetching notifications
  • Resolving likes, reposts, follows
  • Any proxied read that touches an AppView or labeller

A typical Bluesky session generates dozens of these requests per minute. Under the current single-key model, every one of them would require a passkey gesture. In practice this means the signer tab must be open and the user must physically confirm each background request — a continuous stream of biometric prompts for operations the user never consciously initiated. The UX is completely broken.

The forced compromise#

In the absence of a protocol change, a keyless PDS must choose one of two degraded options:

Option A — Sign service-auth JWTs with the user's passkey: Correct from a cryptographic standpoint, but requires a user-presence gesture for every background request. Unusable in practice. Clients time out waiting for signatures that the user has no reason to expect.

Option B — Sign service-auth JWTs with a PDS-held key: The PDS uses its own rotation key or a dedicated key to sign service-auth tokens, and declares the user's passkey only in verificationMethods.atproto. AppViews verify service-auth JWTs against #atproto and reject them because the key doesn't match. Service proxying breaks entirely.

Both options abandon one of the two properties the keyless model is designed to provide. There is no correct choice under the current spec.


Proposed Change#

Add an optional atproto_service verification method#

Allow DID documents to declare an optional second key under verificationMethods, identified by the fragment #atproto_service, with the same encoding as #atproto. When present, this key is used exclusively for service-auth JWT signing. The #atproto key remains the commit signing key.

Example DID document excerpt:

{
  "verificationMethod": [
    {
      "id": "did:plc:example#atproto",
      "type": "Multikey",
      "controller": "did:plc:example",
      "publicKeyMultibase": "zQ3sh..."
    },
    {
      "id": "did:plc:example#atproto_service",
      "type": "Multikey",
      "controller": "did:plc:example",
      "publicKeyMultibase": "zQ3sh..."
    }
  ]
}

Verification rule change#

When verifying a service-auth JWT:

  1. If #atproto_service is present in the issuer's DID document, verify the JWT signature against that key.
  2. If #atproto_service is absent, fall back to #atproto (current behaviour, fully backwards compatible).

Commit signature verification is unchanged: always uses #atproto.

What this enables#

A keyless PDS can now declare its own server-held key as #atproto_service, while the user's passkey occupies #atproto. Each key is used only for its intended purpose:

Operation Key used Signer
Repo commits #atproto (user's passkey) User's passkey
Service-auth JWTs #atproto_service (PDS key) PDS in-process
DID rotation / migration rotationKeys (user's passkey) User's passkey

The user's passkey signs all content. The PDS signs all service-level tokens. DID sovereignty is guaranteed by rotationKeys. All three properties are satisfied simultaneously, with no spurious passkey prompts.


Backwards Compatibility#

The change is fully additive:

  • Existing DID documents contain no #atproto_service entry. Verifiers fall back to #atproto for service-auth, identical to current behaviour.
  • Existing PDSes do not populate #atproto_service. Nothing changes for them.
  • Existing verifiers that do not implement the fallback logic will reject service-auth tokens from keyless PDSes — but they already do that today under Option B above. The change is strictly an improvement for those deployments.
  • The did:plc method supports arbitrary verificationMethod entries, so no PLC directory changes are required.

Alternative Approaches Considered#

1. Cache service-auth tokens across requests#

The PDS obtains a passkey signature once and reuses the resulting JWT for its full lifetime (up to 60 minutes for lxm-scoped tokens). This reduces prompt frequency but does not eliminate it — background token refresh still fires without user intent, and any network interruption forces an immediate re-prompt. The UX remains unpredictable and the approach is fragile.

2. Prompt only for commit-producing operations; skip service-auth#

Silently drop service-auth on proxied requests that don't produce commits. This breaks AppViews and feed generators that require authenticated requests. Feeds stop loading. Notifications don't resolve. The user experience degrades invisibly for operations they did not consciously initiate.

3. Store a derived service key in the browser#

Generate a short-lived P-256 key pair in the browser at signer-connect time, sign the public key with the passkey as a sub-delegation proof, and use the browser key for service-auth JWTs until the session ends.

This improves UX but is not verifiable by AppViews under the current spec — the delegated key is not in the DID document. It also adds significant protocol complexity (sub-delegation format, expiry, revocation) that would need to be standardised. The #atproto_service approach is simpler and keeps all key material in the DID document where verifiers already look.

4. Make service-auth optional for keyless PDSes#

Define a discovery mechanism by which a PDS can advertise that it is keyless and that AppViews should accept unsigned or weakly-signed proxy requests from it.

Why rejected: Eliminates the authenticity guarantee that service-auth provides. An AppView accepting unauthenticated proxy requests cannot verify that the request is on behalf of the claimed user. Introduces a privileged PDS trust tier that conflicts with the decentralised design of ATProto.


Prior Art#

The W3C DID specification supports multiple verificationMethod entries with different purposes via the authentication, assertionMethod, keyAgreement, and capabilityInvocation relationship arrays. ATProto has intentionally kept its DID document model minimal, but the concept of purpose-specific keys is well-established in the broader DID ecosystem.

The OpenID Connect and OAuth 2.0 ecosystems similarly distinguish between keys used for token signing (held by the authorisation server) and keys used for content signing (held by the resource owner).

WebAuthn itself makes the same distinction: the passkey's resident key is used for authentication gestures; service tokens (session cookies, access JWTs) are issued by the relying party server and do not require a hardware gesture on every use.


Summary of Requested Change#

Current Proposed
Commit signing key #atproto #atproto (unchanged)
Service-auth signing key #atproto #atproto_service if present, else #atproto
DID doc change required Optional new verificationMethod entry
Backwards compatible Yes, fully
Enables keyless PDS No Yes

The change is small, additive, backwards compatible, and unlocks an entire class of PDS implementations that improve user sovereignty without sacrificing network interoperability or subjecting users to a continuous stream of passkey prompts for background infrastructure requests they never initiated.