Claude Code Vault-Backed Auth
Background
After pacman -Syu and restowing dots-quantum, Claude Code threw a 401 authentication error on every startup:
Please run /login · API Error: 401
{"type":"error","error":{"type":"authentication_error",
"message":"Invalid authentication credentials"}}
The secure credential setup
The vault is mounted via:
gcvault mount credentials
which makes the vault available at:
/home/evanusmodestus/atelier/_vaults/mounted/credentials/
Claude Code credential files in ~/.claude/ are symlinks into that vault, so all auth material lives encrypted at rest and is never present on disk outside of an active vault session.
Root cause
A Claude Code version upgrade installed by pacman -Syu changed two things:
-
The canonical config/auth path migrated from
~/.claude/credentials.jsonto a new flat file at~/.claude.json. -
Auth token storage split across two files rather than one. The upgrade wrote both as plain files, destroying the existing vault-backed symlink at
~/.claude/.credentials.json.
Additionally, stow owned ~/.claude/.credentials.json via dots-quantum/claude/.claude/, so /login wrote a live bearer token as a plain file into the dotfiles repo — a credential exposure risk.
The two-file auth model (post-upgrade)
| File | Contains | Notes |
|---|---|---|
|
Profile metadata, project list, UI state, |
New file introduced by upgrade. Not previously vault-managed. |
|
Bearer token at |
Overwritten by |
|
Old auth store (pre-upgrade). |
No longer read by Claude Code for auth. Keep for reference. |
Phase 1: Validate the Current State
Run every check before touching anything.
1.1 Confirm the vault is mounted
VAULT_CLAUDE="/home/evanusmodestus/atelier/_vaults/mounted/credentials/claude"
[[ -d "$VAULT_CLAUDE" ]] \
&& echo "✓ vault directory accessible" \
|| echo "✗ vault not mounted — run: gcvault mount credentials"
1.2 Confirm vault status explicitly
gcvault status
Confirm credentials shows ✓ MOUNTED before continuing.
1.3 Inspect the old vault credentials.json
OLD_CREDS="$VAULT_CLAUDE/credentials.json"
[[ -f "$OLD_CREDS" ]] \
&& echo "✓ credentials.json present in vault" \
|| echo "✗ missing: $OLD_CREDS"
[[ -s "$OLD_CREDS" ]] \
&& echo "✓ non-empty" \
|| echo "✗ file is empty"
jq '.oauthAccount.emailAddress // "null"' "$OLD_CREDS"
1.4 Inspect ~/.claude.json
NEW_STORE="$HOME/.claude.json"
if [[ -L "$NEW_STORE" ]]; then
echo "symlink -> $(readlink -f "$NEW_STORE")"
elif [[ -f "$NEW_STORE" ]]; then
echo "plain file ($(wc -c < "$NEW_STORE") bytes)"
else
echo "does not exist"
fi
[[ -s "$NEW_STORE" ]] \
&& echo "✓ non-empty" \
|| echo "✗ empty or missing"
jq '.oauthAccount.emailAddress // "null"' "$NEW_STORE" 2>/dev/null
1.5 Confirm ~/.claude.json carries no accessToken
token=$(jq -r '.claudeAiOauth.accessToken // empty' "$HOME/.claude.json" 2>/dev/null)
[[ -z "$token" ]] \
&& echo "✓ confirmed: no accessToken in ~/.claude.json (expected)" \
|| echo " accessToken present here (unexpected — note for later)"
1.6 Locate the accessToken
grep -rl 'accessToken' "$HOME/.claude/" 2>/dev/null
grep -rl 'accessToken' "$VAULT_CLAUDE/" 2>/dev/null
Expected: ~/.claude/.credentials.json and nothing else.
1.7 Inspect ~/.claude/.credentials.json
LIVE_CREDS="$HOME/.claude/.credentials.json"
if [[ -L "$LIVE_CREDS" ]]; then
actual=$(readlink -f "$LIVE_CREDS" 2>/dev/null)
if [[ -e "$LIVE_CREDS" ]]; then
echo "live symlink -> $actual"
else
echo "broken symlink -> $(readlink "$LIVE_CREDS")"
fi
elif [[ -f "$LIVE_CREDS" ]]; then
echo "plain file ($(wc -c < "$LIVE_CREDS") bytes) — vault symlink was overwritten by /login"
else
echo "does not exist"
fi
token=$(jq -r '.claudeAiOauth.accessToken // empty' \
"$LIVE_CREDS" 2>/dev/null)
[[ -n "$token" ]] \
&& echo "✓ accessToken present (length: ${#token})" \
|| echo "✗ accessToken absent — run claude /login before continuing"
If the token is absent, run claude /login now and re-run 1.7 before proceeding.
|
1.8 Audit the full ~/.claude/ symlink state
for f in credentials.json .credentials.json; do
link="$HOME/.claude/$f"
if [[ -L "$link" ]]; then
target=$(readlink -f "$link" 2>/dev/null)
if [[ -e "$link" ]]; then
echo "✓ $f -> $target"
else
echo "✗ $f is a broken symlink -> $(readlink "$link")"
fi
elif [[ -f "$link" ]]; then
echo " $f is a plain file (not a symlink)"
else
echo " $f does not exist"
fi
done
1.9 Detect stow collision
If 1.8 shows .credentials.json resolving to dots-quantum instead of the vault, stow owns the path. This must be resolved in Phase 2 before vault symlinks can be created.
actual=$(readlink -f "$HOME/.claude/.credentials.json" 2>/dev/null)
expected="$VAULT_CLAUDE/.credentials.json"
if [[ "$actual" == *dots-quantum* ]]; then
echo "✗ stow collision: $actual"
echo " → resolve in Phase 2 before proceeding to Phase 3"
elif [[ "$actual" == "$expected" ]]; then
echo "✓ already vault-backed — skip Phase 2"
else
echo " unexpected target: $actual — investigate"
fi
Phase 2: Resolve Stow Collision
Skip this phase if 1.9 shows the path is already vault-backed.
Stow manages ~/.claude/ from dots-quantum/claude/.claude/.
If /login wrote a bearer token through the stow symlink, a live credential is sitting as a plain file in the dotfiles repo.
2.1 Compare live and vault copies
diff \
"$HOME/.claude/.credentials.json" \
"$VAULT_CLAUDE/.credentials.json" \
&& echo "✓ identical" \
|| echo "✗ differ — the newer file is authoritative"
# Check modification times to determine which is current
ls -la \
"$HOME/.claude/.credentials.json" \
"$VAULT_CLAUDE/.credentials.json"
2.2 Update vault with current token
If the live file is newer (typical after /login):
cp "$HOME/.claude/.credentials.json" "$VAULT_CLAUDE/.credentials.json" \
&& echo "✓ vault copy updated" \
|| echo "✗ copy failed"
diff \
"$HOME/.claude/.credentials.json" \
"$VAULT_CLAUDE/.credentials.json" \
&& echo "✓ identical — vault is current" \
|| echo "✗ still differ — do not proceed"
2.3 Remove credential from dots-quantum
A credential file must never live as a plain file in a git-tracked dotfiles repo.
DOTS_CREDS="/home/evanusmodestus/atelier/_projects/personal/dots-quantum/claude/.claude/.credentials.json"
ls -la "$DOTS_CREDS"
[[ -f "$DOTS_CREDS" && ! -L "$DOTS_CREDS" ]] \
&& rm "$DOTS_CREDS" \
&& echo "✓ removed from dots-quantum" \
|| echo "✗ unexpected state — check manually"
[[ ! -e "$DOTS_CREDS" ]] \
&& echo "✓ confirmed removed" \
|| echo "✗ still present"
2.4 Add to dots-quantum .gitignore
grep '\.credentials\.json' \
/home/evanusmodestus/atelier/_projects/personal/dots-quantum/.gitignore 2>/dev/null \
&& echo "✓ already in .gitignore" \
|| echo ".credentials.json" \
>> /home/evanusmodestus/atelier/_projects/personal/dots-quantum/.gitignore \
&& echo "✓ added to .gitignore"
2.5 Check git history for credential exposure
cd /home/evanusmodestus/atelier/_projects/personal/dots-quantum
git log --all --full-history -- "claude/.claude/.credentials.json"
If commits are returned, the token was committed to version history.
Consider it compromised and rotate via claude /login after completing this runbook.
2.6 Restow and create vault symlink
cd /home/evanusmodestus/atelier/_projects/personal/dots-quantum
stow --restow claude
echo "✓ restow complete"
[[ ! -e "$HOME/.claude/.credentials.json" ]] \
&& echo "✓ path is clear — ready to symlink" \
|| echo "✗ path still occupied — check stow output"
ln -s "$VAULT_CLAUDE/.credentials.json" "$HOME/.claude/.credentials.json" \
&& echo "✓ vault symlink created" \
|| echo "✗ ln failed"
actual=$(readlink -f "$HOME/.claude/.credentials.json" 2>/dev/null)
expected="$VAULT_CLAUDE/.credentials.json"
[[ "$actual" == "$expected" ]] \
&& echo "✓ resolves correctly: $actual" \
|| echo "✗ MISMATCH: got $actual"
live_token=$(jq -r '.claudeAiOauth.accessToken // empty' \
"$HOME/.claude/.credentials.json" 2>/dev/null)
[[ -n "$live_token" ]] \
&& echo "✓ token readable through symlink" \
|| echo "✗ token not readable"
Phase 3: Copy ~/.claude.json into the Vault
This phase captures the new profile/state file. It contains no bearer token but Claude Code reads it on every startup.
3.1 Define paths
VAULT_CLAUDE="/home/evanusmodestus/atelier/_vaults/mounted/credentials/claude"
VAULT_NEW="$VAULT_CLAUDE/claude.json"
3.2 Confirm destination is clear
[[ -e "$VAULT_NEW" ]] \
&& echo "✗ $VAULT_NEW already exists — inspect before overwriting" \
|| echo "✓ destination is clear"
3.3 Confirm source is a plain file
[[ -f "$HOME/.claude.json" && ! -L "$HOME/.claude.json" ]] \
&& echo "✓ source is a plain file — safe to copy" \
|| echo "✗ source is a symlink or missing — check state"
3.4 Copy to vault
[[ -f "$HOME/.claude.json" && ! -e "$VAULT_NEW" ]] \
&& cp "$HOME/.claude.json" "$VAULT_NEW" \
&& echo "✓ copied to vault" \
|| echo "✗ precondition failed — check 3.2 and 3.3 output"
3.5 Validate the copy
src_sum=$(sha256sum "$HOME/.claude.json" | awk '{print $1}')
dst_sum=$(sha256sum "$VAULT_NEW" | awk '{print $1}')
[[ "$src_sum" == "$dst_sum" ]] \
&& echo "✓ checksums match" \
|| echo "✗ CHECKSUM MISMATCH — do not proceed"
jq empty "$VAULT_NEW" 2>/dev/null \
&& echo "✓ valid JSON" \
|| echo "✗ JSON parse error in vault copy"
[[ -s "$VAULT_NEW" ]] \
&& echo "✓ non-empty ($(wc -c < "$VAULT_NEW") bytes)" \
|| echo "✗ vault copy is empty"
Phase 4: Replace ~/.claude.json with a Symlink
4.1 Back up the plain file
cp "$HOME/.claude.json" "$HOME/.claude.json.bak.$(date +%Y%m%d-%H%M%S)" \
&& echo "✓ backup written" \
|| echo "✗ backup failed — do not continue"
4.2 Confirm vault copy still matches
src_sum=$(sha256sum "$HOME/.claude.json" | awk '{print $1}')
dst_sum=$(sha256sum "$VAULT_NEW" | awk '{print $1}')
[[ "$src_sum" == "$dst_sum" ]] \
&& echo "✓ vault copy matches live file — safe to proceed" \
|| echo "✗ MISMATCH — vault copy may have diverged, do not remove source"
4.3 Remove the plain file
[[ -f "$HOME/.claude.json" && ! -L "$HOME/.claude.json" ]] \
&& rm "$HOME/.claude.json" \
&& echo "✓ plain file removed" \
|| echo "✗ file is already a symlink or missing"
4.4 Create the symlink
ln -s "$VAULT_NEW" "$HOME/.claude.json" \
&& echo "✓ symlink created" \
|| echo "✗ ln failed"
4.5 Verify the symlink
actual=$(readlink -f "$HOME/.claude.json" 2>/dev/null)
[[ "$actual" == "$VAULT_NEW" ]] \
&& echo "✓ resolves to correct target: $actual" \
|| echo "✗ MISMATCH — got: $actual"
[[ -L "$HOME/.claude.json" && -e "$HOME/.claude.json" ]] \
&& echo "✓ symlink is live" \
|| echo "✗ symlink is broken"
Phase 5: Smoke Test
5.1 Final symlink audit
for path in "$HOME/.claude.json" "$HOME/.claude/.credentials.json"; do
if [[ -L "$path" && -e "$path" ]]; then
echo "✓ live symlink: $path -> $(readlink -f "$path")"
elif [[ -L "$path" ]]; then
echo "✗ broken symlink: $path -> $(readlink "$path")"
elif [[ -f "$path" ]]; then
echo "✗ plain file (not vault-backed): $path"
else
echo "✗ missing: $path"
fi
done
5.2 Confirm Claude Code authenticates
claude --version
Open a session and run any prompt. Confirm normal operation with no 401 error.
5.3 Remount cycle test
Unmount and remount the vault, then verify the symlinks survive:
gcvault umount credentials
gcvault mount credentials
for path in "$HOME/.claude.json" "$HOME/.claude/.credentials.json"; do
actual=$(readlink -f "$path" 2>/dev/null)
if [[ -n "$actual" ]]; then
echo "✓ resolves after remount: $path -> $actual"
else
echo "✗ broken after remount: $path"
fi
done
live_token=$(jq -r '.claudeAiOauth.accessToken // empty' \
"$HOME/.claude/.credentials.json" 2>/dev/null)
[[ -n "$live_token" ]] \
&& echo "✓ token readable after remount" \
|| echo "✗ token not readable after remount — investigate mount path stability"
If symlinks break after remount, the vault mount path is not stable across sessions.
In that case the claude-relink script in Phase 6 becomes the primary recovery mechanism.
|
Phase 6: Harden Against Future /login Overwrites
The /login flow does not respect existing symlinks.
It writes plain files directly, silently destroying any symlinks in place.
6.1 The relink script
Store in dots-quantum under bin/ and stow to ~/.local/bin/:
#!/usr/bin/env bash
# ~/.local/bin/claude-relink
# Re-establish Claude Code vault-backed symlinks.
# Call this after: gcvault mount credentials
# Call this after: claude /login
set -euo pipefail
VAULT_CLAUDE="/home/evanusmodestus/atelier/_vaults/mounted/credentials/claude"
relink() {
local src="$1"
local dst="$2"
if [[ ! -f "$src" ]]; then
echo "✗ vault source missing: $src"
echo " is the vault mounted? run: gcvault mount credentials"
return 1
fi
if [[ -L "$dst" ]]; then
actual=$(readlink -f "$dst" 2>/dev/null)
if [[ "$actual" == "$src" ]]; then
echo "✓ already correct: $dst"
return 0
fi
echo " relinking: $dst was -> $actual"
rm "$dst"
elif [[ -f "$dst" ]]; then
local backup="${dst}.bak.$(date +%Y%m%d-%H%M%S)"
echo " plain file at $dst — backing up to $backup"
mv "$dst" "$backup"
fi
ln -s "$src" "$dst" \
&& echo "✓ linked: $dst -> $src" \
|| echo "✗ ln failed: $dst -> $src"
}
relink "$VAULT_CLAUDE/.credentials.json" "$HOME/.claude/.credentials.json"
relink "$VAULT_CLAUDE/claude.json" "$HOME/.claude.json"
chmod +x ~/.local/bin/claude-relink
6.2 Wire into shell init
# In ~/.zshrc or equivalent, after gcvault init
if [[ -d "/home/evanusmodestus/atelier/_vaults/mounted/credentials/claude" ]]; then
claude-relink 2>/dev/null
fi
6.3 Why stow cannot manage these files
Stow manages static dotfiles — files whose content is committed to the dotfiles repo.
Vault-backed symlinks are dynamic: the vault must be mounted before the symlink target exists, and the credential content changes at every /login.
The correct division of responsibility:
-
Stow manages the
claude-relinkscript itself (static, committed todots-quantum), CLAUDE.md, settings.json, and other static Claude config files. -
claude-relinkmanages the two dynamic vault symlinks at runtime. -
gcvault manages the vault lifecycle.
6.4 Confirm gitignore coverage
grep -E '\.credentials\.json|^\.claude\.json' "$HOME/.claude/.gitignore" \
&& echo "✓ already in .gitignore" \
|| echo "✗ add .credentials.json to ~/.claude/.gitignore"
Summary
Vault file inventory
| Live path | Vault path | Role |
|---|---|---|
|
|
Profile metadata, project list, UI state. No bearer token. |
|
|
Bearer token at |
|
|
Old auth store (pre-upgrade). No longer read by Claude Code. |
Operational checklist
# Standard startup sequence
gcvault mount credentials
claude-relink
# After any claude /login
claude-relink
# Verify state at any time
for path in "$HOME/.claude.json" "$HOME/.claude/.credentials.json"; do
[[ -L "$path" && -e "$path" ]] \
&& echo "✓ $path -> $(readlink -f "$path")" \
|| echo "✗ $path not vault-backed"
done
Appendix: Bash Test Primer
Every mutating operation in this runbook is gated behind an explicit [[ ]] precondition.
Why [[ ]] over [ ]
[[ ]] is a Bash built-in keyword, not an external command:
-
Unquoted variables are safe — no word splitting or glob expansion.
-
&&and||work inside without escaping. -
Additional operators:
=~for regex,</>for lexicographic comparison. -
Clean exit codes:
0for true,1for false.
File and path test operators
| Operator | What it tests |
|---|---|
|
Path exists — any type. Returns false for a broken symlink. |
|
Exists and is a regular file. |
|
Exists and is a directory. |
|
Exists and is a symbolic link. Returns true even for broken symlinks. |
|
Exists and is readable. |
|
Exists and has a size greater than zero. |
|
Exists and has been modified since last read. |
The -L vs -e distinction for symlinks
This distinction matters throughout this runbook because /login destroys symlinks by writing plain files over them.
# Is this path a symlink? (true even if target missing)
[[ -L "$link" ]] && echo "is a symlink"
# Does this path resolve to something real?
[[ -e "$link" ]] && echo "exists and resolves"
# Confirm it is a symlink AND the target exists
[[ -L "$link" && -e "$link" ]] && echo "live symlink"
# Detect a broken symlink specifically
[[ -L "$link" && ! -e "$link" ]] && echo "broken symlink"
Verifying a symlink target
link="$HOME/.claude/.credentials.json"
expected="$VAULT_CLAUDE/.credentials.json"
actual=$(readlink -f "$link" 2>/dev/null)
[[ "$actual" == "$expected" ]] \
&& echo "✓ points to correct target" \
|| echo "✗ MISMATCH: got $actual"
The || continuation rule in zsh
Zsh executes each line as a complete command.
Placing || at the start of a new line without \ on the preceding line causes a parse error.
# WRONG — zsh sees two separate commands
[[ -n "$token" ]]
|| echo "missing"
# CORRECT — backslash continuation
[[ -n "$token" ]] \
|| echo "missing"
# ALSO CORRECT — operator at end of line
[[ -n "$token" ]] ||
echo "missing"