SOPs
claude -p Credentials — AWS Parameter Store
The Symptom
Section titled “The Symptom”claude -p exits non-zero (or prints to stdout via the JSON envelope), saying:
Not logged in · Please run /loginor, when the CLI has stale credentials and the server has rotated them:
Failed to authenticate. API Error: 401 Invalid authentication credentialsWhen this happens, every autonomous caller of claude -p silently fails — session namer, voice nudge classifier, screenshot renamer, Reddit listener, dozens of skill scripts, every launchd cron job that uses Claude.
The sneaky part: nothing alerts. Sessions stop getting names. Screenshots land with raw camera-default filenames. The listener’s classifier returns nothing and the pipeline silently does no work.
The Architecture (Post 2026-05-19)
Section titled “The Architecture (Post 2026-05-19)”┌─────────────────────────────────────────────────────────────────┐│ AWS Parameter Store (us-west-2) ││ /claude/oauth/credentials SecureString ←── single source │└─────────────────────────────────────────────────────────────────┘ ▲ writer │ reader │ (only Studio + MBP) │ (every machine) │ ▼┌──────────────────────┐ ┌──────────────────────────┐│ launchd WatchPaths │ │ claude-p-wrapper ││ watches │ │ on auth_failure detect: ││ ~/.claude/.credentials.json │ 1. run pull script ││ → run publish script │ │ 2. retry claude -p once││ on every change │ │ 3. log + alert if ││ (= every /login, │ │ retry still fails ││ every refresh) │ └──────────────────────────┘└──────────────────────┘Auto-publish, auto-pull, auto-recover. Nothing requires James to copy a file, rerun a script, or remember a procedure.
The Real Auth Order
Section titled “The Real Auth Order”The claude binary checks credentials in this order:
CLAUDE_CODE_OAUTH_TOKENenv varANTHROPIC_API_KEYenv var~/.claude/.credentials.jsonfile- macOS keychain entry
Claude Code-credentials
We use (3) as the on-machine source. The keychain (4) auto-locks per-security-session and breaks every headless context; the file does not. Parameter Store keeps the file fresh.
Components
Section titled “Components”IAM (account 874393260228, region us-west-2)
Section titled “IAM (account 874393260228, region us-west-2)”Two scoped IAM users, both restricted to the single ARN
arn:aws:ssm:us-west-2:874393260228:parameter/claude/oauth/credentials:
| User | Policy | What it can do | Lives on |
|---|---|---|---|
claude-creds-reader | claude-creds-reader-policy | ssm:GetParameter, kms:Decrypt via SSM | Studio, MBP, Remote Mac, VPS |
claude-creds-writer | claude-creds-writer-policy | adds ssm:PutParameter, kms:Encrypt via SSM | Studio + MBP only |
The split exists so a compromise of a reader-only machine (Remote Mac, VPS) cannot inject a malicious token back into the canonical store. Audit trail in CloudTrail also distinguishes reader vs writer activity.
AWS Profiles (~/.aws/credentials per machine)
Section titled “AWS Profiles (~/.aws/credentials per machine)”[claude-creds-reader]on all four machines[claude-creds-writer]on Studio + MBP only
Profiles live in the local credentials file with chmod 600. The writer key is never published to secrets.json or any synced surface — losing it costs one IAM delete-access-key + create-access-key cycle to rotate.
Scripts (~/apps/cc/recipes/)
Section titled “Scripts (~/apps/cc/recipes/)”claude-creds-publish.sh— reads~/.claude/.credentials.json, runsaws ssm put-parameterwith the writer profile. Validates JSON before publishing, refuses to push garbage, logs every result to~/apps/cc/logs/claude-creds.log.claude-creds-pull.sh—aws ssm get-parameter --with-decryptionwith the reader profile, strips the trailing newline that--output textappends, validates JSON, writes to~/.claude/.credentials.jsonwithchmod 600._claude-creds-common.sh— shared helpers:find_aws(handles brew-on-Mac vs pip-on-Linux vs~/.local/binsymlink on Remote Mac),log_event,send_alert(Resend viagmail-notify/send-email.py).
Auto-Publish (launchd, Studio + MBP)
Section titled “Auto-Publish (launchd, Studio + MBP)”~/Library/LaunchAgents/com.cc.claude-creds-publisher.plist uses WatchPaths on ~/.claude/.credentials.json. Any change to the file — first creation, claude /login overwrite, token refresh — fires the publisher within ~10 seconds. ThrottleInterval=10 prevents burst-fire from rapid writes.
The plist source is at ~/apps/cc/launchd/com.cc.claude-creds-publisher.plist. To install on a new writer machine:
cp ~/apps/cc/launchd/com.cc.claude-creds-publisher.plist ~/Library/LaunchAgents/launchctl load ~/Library/LaunchAgents/com.cc.claude-creds-publisher.plistlaunchctl list | grep claude-creds # verifyAuto-Recover (claude-p-wrapper, every machine)
Section titled “Auto-Recover (claude-p-wrapper, every machine)”~/apps/claude-p-wrapper/claude-p Build 10+ detects both auth-failure shapes:
- Shape 1 — JSON envelope:
{"is_error":true,"result":"Not logged in · Please run /login",...}(stale credentials) - Shape 2 — plain text:
Not logged in · Please run /loginto stdout (no credentials file at all)
On either detection, the wrapper runs claude-creds-pull.sh, retries the underlying claude -p call once, and returns the retry’s output if successful. If pull or retry fails, it falls through to the existing email-alert + exit-78 path.
Each log line gets two fields: recovery_attempted and recovery_succeeded.
Set CLAUDE_P_NO_RECOVER=1 to bypass — used by tests that need to observe raw auth-failure behavior.
Smoke Test (Any Machine)
Section titled “Smoke Test (Any Machine)”mv ~/.claude/.credentials.json /tmp/backup.jsonpython3 ~/apps/claude-p-wrapper/claude-p "reply with single word ok" < /dev/nullExpected:
- stderr:
[claude-p] auth lapsed; pulled fresh credentials from Parameter Store, retrying once. - stdout:
ok ~/.claude/.credentials.jsonnow exists with mode600
Cleanup (the auto-pulled file is the real current credential — keeping it is fine):
rm /tmp/backup.jsonIf the test prints Not logged in instead of ok, check:
~/.aws/credentialson this machine has[claude-creds-reader]aws --profile claude-creds-reader sts get-caller-identitysucceeds~/apps/cc/logs/claude-creds.logfor the most recent pull error
Initial Provisioning of a New Machine
Section titled “Initial Provisioning of a New Machine”Reader-only:
# 1. Install awscli (one of these, depending on platform)brew install awscli # Mac with brewpip3 install --user awscli # Mac without brew → ~/Library/Python/<ver>/bin/aws (symlink to ~/.local/bin)pip3 install --break-system-packages awscli # Debian/Ubuntu VPS as root
# 2. Drop the reader profile (Studio or MBP has the access key in their ~/.aws/credentials)mkdir -p ~/.aws && chmod 700 ~/.awscat >> ~/.aws/credentials <<'EOF'
[claude-creds-reader]aws_access_key_id = AKIA… ← copy from a writer machineaws_secret_access_key = …region = us-west-2EOFchmod 600 ~/.aws/credentials
# 3. Smoke-testaws ssm get-parameter --name /claude/oauth/credentials --with-decryption --profile claude-creds-reader --query 'Parameter.Version' --output textWriter-additional (Studio or MBP only — never on Remote Mac or VPS):
cat >> ~/.aws/credentials <<'EOF'
[claude-creds-writer]aws_access_key_id = AKIA…aws_secret_access_key = …region = us-west-2EOFchmod 600 ~/.aws/credentials
# Install the publishercp ~/apps/cc/launchd/com.cc.claude-creds-publisher.plist ~/Library/LaunchAgents/launchctl load ~/Library/LaunchAgents/com.cc.claude-creds-publisher.plistWhen the Token Rotates
Section titled “When the Token Rotates”claude /login (or any access-token refresh) overwrites ~/.claude/.credentials.json. The launchd publisher fires, pushes the new value to Parameter Store, version increments. All four machines now have access to the fresh credential the moment any of them tries to claude -p and fails — auto-recovery pulls it.
You do not need to do anything. The whole point.
Key Rotation
Section titled “Key Rotation”To rotate the writer key (e.g., if a writer machine is decommissioned):
# Studioaws iam list-access-keys --user-name claude-creds-writeraws iam create-access-key --user-name claude-creds-writer # save AccessKeyId + SecretAccessKey# Edit ~/.aws/credentials on Studio + MBP, replace the [claude-creds-writer] blockaws iam delete-access-key --user-name claude-creds-writer --access-key-id <OLD_ID>Reader rotation is identical with claude-creds-reader and writing to all four machines.
Why Not secrets.json Anymore
Section titled “Why Not secrets.json Anymore”The previous design (2026-05-13) snapshotted the credential into secrets.json as CLAUDE_OAUTH_CREDENTIALS. Problems:
- No automation kept it fresh. When the OAuth refresh token rotated server-side, the snapshot became invalid and no one knew until
claude -pstarted failing across the fleet. - No auto-recovery. A machine with a stale file had no mechanism to fetch a fresh one.
secrets.jsonis broadly synced. Every entry on Studio scp’s to MBP, Remote Mac, and VPS. Writer-level capability there violates least privilege.
Parameter Store fixes all three: auto-published on every change, auto-pulled on every failure, and IAM splits read from write.
The CLAUDE_OAUTH_CREDENTIALS entry has been removed from secrets.json as of 2026-05-19. Anything that previously referenced it should call ~/apps/cc/recipes/claude-creds-pull.sh instead.
History
Section titled “History”- 2026-05-08 — keychain
no-timeoutsetting documented. Not applied. Predicted not to be durable; was right. - 2026-05-11 —
no-timeoutapplied on Studio. Re-locked within hours via lock-on-sleep. - 2026-05-13 — file-based
~/.claude/.credentials.jsonapproach + one-time snapshot tosecrets.json. No automation tied it together. - 2026-05-19 — same problem returns. Both Studio and MBP missing the file; secrets.json snapshot returns 401 (refresh token rotated, no one re-snapshotted). Three “durable” fixes regressed in 11 days because each was documented but not automated.
- 2026-05-19 — Parameter Store + launchd auto-publish + wrapper auto-recover lands. End-to-end test on MBP passes: delete
.credentials.json, runclaude -p, returns clean output in 12.88 seconds with no human action.
See Also
Section titled “See Also”- Handling Secrets — general policy for credentials
- Notify-James Pattern — how the alert script reaches James’s inbox
- Repo Sync Policy — why writer creds are file-local, not synced