name: "atproto-app-password"
description: "Make an atproto app password for one account and one purpose, store it in nate's sops store, and verify it without printing it."
createdAt: "2026-11-02T01:12:23.033482+00:00"
repoUrl: "https://skillz.supply/skills/did:plc:xbtmt2zjwlrfegqvch7fboei/skills.atproto-app-password/SKILL.md"
# atproto-app-password
Load `secrets` first: its hard rules apply, above all that no value is ever
printed. Each app password serves one purpose on one account, so revoking it
touches nothing else.
## make and store
The store is `~/tangled.org/zzstoatzz.io/secrets/prod.yaml`. The key is
`["atproto"]["<account>"]["app-password"]`, where `<account>` is the handle with
dots as dashes (`zzstoatzz.io` → `zzstoatzz-io`). Read the existing
`atproto.<account>` entry first; if the account uses another key, follow it.
Nate runs this himself, because it prompts for the account password and maybe an
emailed sign-in code. Give him the one line, filled in:
```bash
~/tangled.org/zzstoatzz.io/scripts/make-atproto-app-password <handle> "<purpose>" | jq -R . | sops set --value-stdin ~/tangled.org/zzstoatzz.io/secrets/prod.yaml '["atproto"]["<account>"]["app-password"]'
```
`sops set` takes a JSON value, so the bare password has to become a JSON string.
`jq -R .` does that. `jq -Rj .` does not: `-j` turns on raw output and strips the
quotes again, and sops answers "Value for --set is not valid JSON". The script
writes no trailing newline, so nothing needs `tr`. If the script fails, jq passes
nothing on and sops refuses the empty value, leaving the old one in place.
The account password can come from `ATPROTO_PASSWORD` instead of the prompt, for
example from sops when the store holds it. Add `--privileged` only when the
consumer sends DMs.
## verify without printing
```bash
f=~/tangled.org/zzstoatzz.io/secrets/prod.yaml; k='["atproto"]["<account>"]["app-password"]'
v="$(sops decrypt --extract "$k" "$f")"; printf %s "$v" | wc -c; printf %s "$v" | grep -qE '^[a-z0-9]{4}(-[a-z0-9]{4}){3}$' && echo ok || echo fail; unset v
sops decrypt --extract "$k" "$f" | jq -Rc '{identifier:"<handle>",password:.}' | curl -s --max-time 10 -X POST <pds>/xrpc/com.atproto.server.createSession -H 'content-type: application/json' --data-binary @- | jq -r 'if .accessJwt then (.accessJwt|split(".")[1]|@base64d|fromjson|.scope) else .error end'
```
Expect `19`, `ok`, and `com.atproto.appPass`. `com.atproto.access` means a full
account password went into the key. The script prints the PDS on stderr, and
`com.atproto.identity.resolveHandle` finds it too.
## after
- Record the entry in the store's README: account, purpose, consumers, and the
rotation answer. Commit `prod.yaml` and the README by path; `just check` first.
- Push it to consumers the store already syncs (`just status`, `just sync`).
Consumers outside the store read it from sops at the moment they run.
- Revoke whatever it replaces. `com.atproto.server.revokeAppPassword` takes the
name, but needs a full account session. Usually nate removes it from the app
passwords list in bsky.app's settings. If the key held
the account password, there is nothing to revoke, but ask whether it was ever
exposed and should be changed.