Atproto App Password

by @zzstoatzz.io · created Nov 2, 2026

Install this skill

npx @skillz.supply/cli install did:plc:xbtmt2zjwlrfegqvch7fboei/skills.atproto-app-password

Writes SKILL.md into your agent's skills folder under this skill's name. No CLI? Download the file, or view it.

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.