I’ve been working on password reset and account recovery in atproto-pds and atproto-entryway, and I’ve landed on a feature I want to discuss broadly, get feedback on, and propose to the community.
Problem
Password and account recovery is strongly tied to email, and there aren’t any on-protocol or protocol-adjacent solutions that don’t require the identity to have immediate access to a single inbox. I have some mixed feelings about using email addresses and cell phone numbers that I’m not going to get into here, but the tl;dr is that reliance on them during an incident is precarious.
There are lots of reasons that you may not have access to incoming email from your PDS operator including, and not limited to, not having access to a shared inbox and related shared-account woes, being locked out of an email account, DNS*, etc.
Regardless, you’re left asking the operator for help, and on a network where the whole point is that your identity doesn’t depend on the operator’s goodwill, that’s a bad place to end up.
Another thing that bugs me is that account holders already have a credential that outranks their account password: rotation keys. A rotation key on a did:plc identity can sign a PLC operation that moves the identity to a different PDS, and that is a much more powerful proof of ownership than a password. Letting a rotation key get you back into your account recognizes that holder’s authority.
Design
The existing password reset flow is established as a “request a token, use the token” process that incorporates email.
To reset a password, the account holder needs to have both a server-generated token (like “ABCD-1234”) that was given to them by invoking the com.atproto.server.requestPasswordReset XRPC method with the email address associated with the account in question. The account holder then invokes the com.atproto.server.resetPassword XRPC method with both the token and password fields to change the account password.
I propose minting and submitting an account recovery JWT token instead of the server-generated token. This account recovery JWT (as {"typ": "recovery+jwt"}) proves ownership of the rotation key because only the holder can sign the JWT with it. A single-use, short-lived recovery JWT can be used in place of the PDS-generated token with the resetPassword XRPC method. From there, resetPassword works exactly as it does today.
This is an addition, not a replacement. The account portal’s “enter your code and a new password” page is the same page for both paths. A client with an “I already have a code” screen works without knowing recovery keys exist.
Solution
The account recovery JWT is a compact JWS that describes the identity and signature proof, and includes reasonable, paved-path security constraints.
The header includes the algorithm (alg), type (typ), and key identifier (kid). The JWT type limits this JWT’s use, so it can’t be confused with other token types, such as general OAuth access tokens or inter-service authentication tokens. The key identifier is the did-method-key serialized public key that must be byte-for-byte identical to one of the rotation keys (rotationKeys) in the DID document.
The claims include the issuer (iss), audience (aud), issued at (iat), expiration time (exp), and JWT ID (jti). Both the issuer and audience are DIDs that map to the identity that issued the JWT and the identity meant to receive and process the token, in this case the PDS on which the identity’s account is on. The issued at and expires at claims ensure short-lived tokens are created to prevent possible abuse. The JWT ID ensures the token cannot be replayed, and servers that consume this type of account recovery token must take special care to prevent reuse.
{
"alg": "ES256K",
"typ": "recovery+jwt",
"kid": "did:key:zQ3sh…"
}
{
"iss": "did:plc:ewvi7nxzyoun6zhxrhs64oiz",
"aud": "did:web:mayon.pyroclastic.cloud",
"iat": 1760000000,
"exp": 1760000300,
"jti": "Nk3n0WsT1F9m8Q2aXv7cLg"
}
The typ does not reuse the existing jwt, at+jwt, or refresh+jwt consumed by the PDS. There is also no lxm because the type already scopes the token to a single, specific purpose.
Rotation Key Constraints
PLC lists rotation keys in order of authority. A key earlier in the list can override (within the 72-hour recovery window) an operation signed by a key later in it. A PDS holds rotation keys for its accounts so it can sign PLC operations on their behalf. The rule I landed on is that the key signing a recovery token must rank above every rotation key the service signs with.
For example, if the rotation key list looks like [A, B, C, D] where the operator issues B and C, then only A could be used to recover an account.
A key ranked below the service’s is a key the service can override, so it represents control the service could take back. A key ranked above can override the service. Only the second kind is the right thing to hang a reset credential on.
There’s also a specific attack this closes. Someone who briefly holds a session and an email token can have the service sign a PLC operation that appends a rotation key. If any rotation key could mint a reset code, that appended key is a backdoor that survives a password change and an email change. Under this rule, it mints nothing, and the real holder, with a higher key, can remove it.
Additionally, the service’s own keys never qualify. The operator can’t walk this path on its own accounts, and a leaked operator rotation key is not a reset credential for every account the service hosts.
The set the rule compares against is the keys the service signs PLC operations with online. If a service has no rotation key for an identity at all, like an account that migrated while keeping its own keys, any rotation key qualifies. Granted, this is a weird edge case that I hope is just theoretical.
Reference Implementation
This is implemented and deployed as a feature of atproto-pds and atproto-entryway within atproto-crates. The atproto-identity crate has both recovery_token::mint and recovery_token::verify for minting and verifying the account recovery JWTs. The verifier takes the token, the accepted audiences, the current time, the lifetime ceiling, the identity’s current rotationKeys and the public halves of the service’s own keys. It hands back the verified claims or a typed refusal.
atpdid recovery-token mints a token from a rotation key kept in a file or an environment variable. It reads the DID’s audit log, checks the key is a current rotation key, and prints the token on stdout. atpdid recovery-redeem takes that token and the new password and posts both to com.atproto.server.resetPassword. It sends them to the server the token is addressed to: the token’s did:web audience, or the PDS named in the issuer’s verified PLC log, unless --pds says otherwise. The two commands are separate so that the minting machine can stay offline.
atpdid recovery-token --did did:plc:... \
--aud did:web:mayon.pyroclastic.cloud \
--key recovery.key > token.txt
atpdid recovery-redeem token.txt --password new-password.txt
The resetPassword XRPC takes a token and a password, with both as plain strings. The server does a small shape-check to see if the token looks like a JWT, and then proceeds to parse it as an account recovery JWT if it does. This one call checks the token, spends it, and resets the password. The lexicon defines only InvalidToken and ExpiredToken, so any other refusal comes back as InvalidToken, with a message saying which check failed, which feels appropriately vague.
The account portal’s forgot-password page offers two options: a code by email, or a recovery token plus a new password. atproto-entryway does the same for a cluster and accepts either its own DID or the hosting member’s DID as aud. A member PDS in entryway mode forwards resetPassword to the entryway, as it already did.
Because it’s an existing method, there’s no lexicon to publish or resolve, and any client that can call resetPassword can redeem a recovery token. A server without the feature looks the token up as an emailed code, finds nothing, and answers InvalidToken. A server that has the feature but has it switched off says so in the InvalidToken message. It’s deployed on Mayon and Vesuvius, and you can see the whole flow at https://mayon.pyroclastic.cloud/account/password/forgot.
Closing
I believe this is a worthwhile measure to ensure account holders can recover their accounts when they otherwise couldn’t. This builds on the strengths of did-method-plc and verification methods, and has the big benefit of not requiring any additional change to the existing password reset lexicon schema. The rank rule matches the authority model PLC already has instead of inventing one, and the verifier is a simple process that any implementation can lift.
Counterarguments and caveats: it serves did:plc identities, and only those whose holder has set up a rotation key above the service’s, which today is a minority of accounts. It adds a token type every bearer-token verifier has to refuse. It puts PLC resolution behind an unauthenticated endpoint, which means rate limiting and caching are part of the implementation rather than nice-to-haves.
The code is in the atproto-crates workspace: the token module is at https://tangled.org/ngerakines.me/atproto-crates/blob/main/crates/atproto-identity/src/recovery_token.rs.
