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:

  1. The canonical config/auth path migrated from ~/.claude/credentials.json to a new flat file at ~/.claude.json.

  2. 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

~/.claude.json

Profile metadata, project list, UI state, oauthAccount profile object. Contains no bearer token.

New file introduced by upgrade. Not previously vault-managed.

~/.claude/.credentials.json

Bearer token at .claudeAiOauth.accessToken — the actual OAuth session material.

Overwritten by /login as a plain file, destroying any existing symlink.

~/.claude/credentials.json

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.
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.

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"

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"
ln -s "$VAULT_NEW" "$HOME/.claude.json" \
  && echo "✓ symlink created" \
  || echo "✗ ln failed"
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

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.

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-relink script itself (static, committed to dots-quantum), CLAUDE.md, settings.json, and other static Claude config files.

  • claude-relink manages 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

~/.claude.json

$VAULT_CLAUDE/claude.json

Profile metadata, project list, UI state. No bearer token.

~/.claude/.credentials.json

$VAULT_CLAUDE/.credentials.json

Bearer token at .claudeAiOauth.accessToken. Primary auth material.

~/.claude/credentials.json

$VAULT_CLAUDE/credentials.json

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: 0 for true, 1 for false.

File and path test operators

Operator What it tests

-e "$path"

Path exists — any type. Returns false for a broken symlink.

-f "$path"

Exists and is a regular file.

-d "$path"

Exists and is a directory.

-L "$path"

Exists and is a symbolic link. Returns true even for broken symlinks.

-r "$path"

Exists and is readable.

-s "$path"

Exists and has a size greater than zero.

-N "$path"

Exists and has been modified since last read.

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"
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"