An app-password session (scope com.atproto.appPass) can call all of these:
com.atproto.identity.requestPlcOperationSignature
com.atproto.identity.signPlcOperation
com.atproto.identity.submitPlcOperation
com.atproto.identity.getRecommendedDidCredentials
Each route in packages/core/src/handlers/xrpc-identity.js (around l. 788–806) is auth: 'required', and its handler checks only that auth is present, never its scope. A PLC operation can replace an account's rotation keys, signing key and PDS endpoint. So anyone holding a leaked app password can take the identity over for good, and revoking the app password afterwards doesn't undo it.
The email confirmation doesn't stop this. canConfirmByEmail() is false on a PDS with no mailer or no verified address, and then requestPlcOperationSignature answers {} and signPlcOperation asks for no token.
This was seen on grain-pds (grain.social) on 2026-11-01:
createSession with an app password returned scope com.atproto.appPass.
requestPlcOperationSignature answered 200 {}.
signPlcOperation returned a signed operation.
submitPlcOperation answered 200.
That operation was deliberate: it added a recovery key. It shows the path is open.
What the reference PDS does
@atproto/pds refuses app passwords on these methods: they need a full access session (its accessFull auth). Clients such as goat, and the Bluesky app's account settings, sign in with the account password for PLC changes for that reason.
Fix
Refuse an app-password scope on the four routes, privileged app passwords included. Return 403 InvalidToken, as requireFullSession in xrpc-server.js already does for app-password management. Ideally the identity handlers would share that helper, or auth: 'full' would become a route option, so the next privileged route can't forget it.
com.atproto.identity.updateHandle is worth checking too. The reference PDS lets an app password change the handle only when it is privileged.
Tests should show that an app-password session gets 403 from each route and a password session still works.
What happens
An app-password session (scope
com.atproto.appPass) can call all of these:com.atproto.identity.requestPlcOperationSignaturecom.atproto.identity.signPlcOperationcom.atproto.identity.submitPlcOperationcom.atproto.identity.getRecommendedDidCredentialsEach route in
packages/core/src/handlers/xrpc-identity.js(around l. 788–806) isauth: 'required', and its handler checks only thatauthis present, never its scope. A PLC operation can replace an account's rotation keys, signing key and PDS endpoint. So anyone holding a leaked app password can take the identity over for good, and revoking the app password afterwards doesn't undo it.The email confirmation doesn't stop this.
canConfirmByEmail()is false on a PDS with no mailer or no verified address, and thenrequestPlcOperationSignatureanswers{}andsignPlcOperationasks for no token.This was seen on grain-pds (grain.social) on 2026-11-01:
createSessionwith an app password returned scopecom.atproto.appPass.requestPlcOperationSignatureanswered 200{}.signPlcOperationreturned a signed operation.submitPlcOperationanswered 200.That operation was deliberate: it added a recovery key. It shows the path is open.
What the reference PDS does
@atproto/pdsrefuses app passwords on these methods: they need a full access session (itsaccessFullauth). Clients such as goat, and the Bluesky app's account settings, sign in with the account password for PLC changes for that reason.Fix
Refuse an app-password scope on the four routes, privileged app passwords included. Return
403 InvalidToken, asrequireFullSessioninxrpc-server.jsalready does for app-password management. Ideally the identity handlers would share that helper, orauth: 'full'would become a route option, so the next privileged route can't forget it.com.atproto.identity.updateHandleis worth checking too. The reference PDS lets an app password change the handle only when it is privileged.Tests should show that an app-password session gets 403 from each route and a password session still works.