Self-hosted broker that centrally refreshes Claude Code OAuth credentials for all your machines, with quota-aware multi-account rotation.
A small self-hosted broker that holds Claude Code (Anthropic subscription) OAuth credentials centrally, refreshes each one in a single place, and hands them out to your machines. This makes it safe to use one Claude login across several computers without the refresh-token rotation logouts you otherwise hit.
Claude Code's subscription OAuth uses short-lived access tokens (~8h) plus a refresh token that rotates on every refresh: when a token is refreshed, the old refresh token is invalidated. If two machines share the same credential, whichever refreshes first wins and the others are forced to log in again.
Copying the credential file around does not fix this — there is always a race between one machine refreshing and the copy propagating. The only robust fix is to make exactly one component responsible for refreshing, and have every machine read from it. That component is this broker.
┌──────────────────────── broker host (private) ─────────────┐
│ ccbrokerd │
│ ├─ encrypted store (AES-256-GCM) one record per cred │
│ ├─ refresh manager single-flight, refresh ~1h early │
│ │ POST https://api.anthropic.com/v1/oauth/token │
│ ├─ credential API :8787 Bearer token + per-cred scope │
│ └─ admin API 127.0.0.1:8788 X-Admin-Token │
└───────────────────────────────┬────────────────────────────┘
(private tunnel, e.g. Tailscale / WireGuard)
┌───────────────────────────────────┼───────────────────────────┐
ccb ccb ccb
(macOS: Keychain) (Linux: file) (Linux: file)
writes "Claude Code-credentials" ~/.claude/.credentials.json ~/.claude-work/...
ccbrokerd— the daemon. Owns the credentials, refreshes them before expiry (single-flight per credential, exponential-ish backoff on transient failures, marks a credential dead oninvalid_grant), and serves them over a bearer-authenticated, scope-limited API. A separate localhost-only admin API imports / lists / deletes / force-refreshes credentials.ccb— the client. Pulls named credentials on an interval and writes them to local destinations: a.credentials.jsonfile (Linux) or the macOS Keychain item Claude Code reads (Claude Code-credentials). Because the agent keeps the local token fresh, Claude Code never needs to refresh it itself, so it never rotates the broker's refresh token.
The documented token endpoint https://console.anthropic.com/v1/oauth/token
sits behind a Cloudflare managed challenge that blocks non-browser clients.
https://api.anthropic.com/v1/oauth/token serves the same
grant_type=refresh_token exchange without the challenge, which is what the
broker uses.
Client (ccb) — macOS or Linux:
curl -fsSL https://raw.githubusercontent.com/Dev-Jahn/ccbroker/main/install.sh | sh
ccb setupinstall.sh downloads the release binary for your OS/arch, verifies it against
checksums.txt, and installs it as one real file at
~/.config/ccbroker/bin/ccb — then symlinks ~/.local/bin/ccb (override the
symlink's directory with CCB_INSTALL_DIR; /usr/local/bin when run as root) at
it. An older install that left a real binary in that directory is replaced by the
symlink. ccb setup then walks you through the client config.
That indirection is the whole update story: the launchd/systemd unit ccb setup
writes, the statusline command and your PATH all name paths that resolve to that
one file, so an update only ever has to replace it — no unit file, no PATH entry
and no per-machine step. The Claude Code plugin below does exactly that
automatically.
Broker (ccbrokerd) — Linux with systemd, run as root:
curl -fsSL https://raw.githubusercontent.com/Dev-Jahn/ccbroker/main/install-server.sh | sudo shinstall-server.sh installs ccbrokerd, generates a master key, an admin token
and a first client token, writes /etc/ccbroker/config.json, installs and starts
the systemd service, and prints the tokens once.
Manual. Grab a prebuilt binary for your platform from the
releases page (assets are named
ccb_<os>_<arch> / ccbrokerd_linux_<arch>, with a checksums.txt) and put it
at ~/.config/ccbroker/bin/ccb with a symlink to it from a PATH directory, or
build the client with Go:
go install github.com/Dev-Jahn/ccbroker/cmd/ccb@latest- Bring the broker up on a server you control — the server one-liner above
sets up
ccbrokerd, a config and a systemd unit, and prints an admin token and a client token. - Import a credential from an existing Claude login (see Import a credential).
- On each machine, install the client and run
ccb setup— it asks for the broker URL and client token, writesagent.json, and offers to install a launchd/systemd job that keeps the credential fresh. - Done. Claude Code on that machine now reads a token the broker refreshes;
use
ccb status/ccb use/ccb policyto manage accounts.
The server one-liner (install-server.sh) does all of this for you.
To set the broker up by hand:
# 1. master key
ccbrokerd genkey > /etc/ccbroker/master.key && chmod 600 /etc/ccbroker/master.key
# 2. a client token (give the token to the machine; store only its hash)
TOKEN=$(head -c32 /dev/urandom | od -An -tx1 | tr -d ' \n')
echo "client token: $TOKEN"
printf %s "$TOKEN" | ccbrokerd hashtoken # -> put in config.json clients[].tokenSha256
# 3. config.json (see examples/config.example.json), then:
ccbrokerd serve -c /etc/ccbroker/config.jsonSeed the broker from an existing login (~/.claude/.credentials.json, or the
JSON stored in the macOS Keychain item Claude Code-credentials):
curl -sS -X PUT -H "X-Admin-Token: $ADMIN_TOKEN" \
--data @credentials.json \
http://127.0.0.1:8788/admin/creds/personalThe body may be a full {"claudeAiOauth": {...}} file or the bare oauth object;
a refreshToken is required. From then on the broker refreshes it.
ccb setup # interactive first-run wizard: writes agent.json, installs the watch daemon
ccb sync # one-shot sync (offer local /login, adopt, write active) — `pull` is a kept alias
ccb watch # foreground daemon: long-poll the broker and sync on every rotation
ccb ensure-alive # start `ccb watch` if it is not running (cron fallback)
ccb run # loop on intervalSec
ccb use work # switch the "@active" account and sync
ccb auto # switch to the least-utilized account and sync
ccb status # quota table for all accounts in scope
ccb statusline # one-line summary for a Claude Code statusLine
ccb policy all # show or set the auto-rotation policy (manual|account|all)
ccb version # print the ccb versionSee examples/agent.example.json. Targets:
{"type":"file","path":"~/.claude/.credentials.json"}— Linux and anywhere Claude Code reads the file.{"type":"keychain"}— macOS; updates theClaude Code-credentialsKeychain item, reusing the account of the existing item.
Optional proxyUrl reaches the broker through an explicit http(s)://,
socks5:// or socks5h:// proxy — e.g. "proxyUrl": "socks5://localhost:1055"
on a host whose tailscaled runs with --tun=userspace-networking, where tailnet
IPs are only reachable through its SOCKS5 server. As of v0.3.3, when proxyUrl
is empty the agent honors the standard proxy environment variables
(HTTP_PROXY/HTTPS_PROXY/ALL_PROXY/NO_PROXY) — earlier versions ignored
them; if a global proxy is set, NO_PROXY can exempt the broker host.
Instead of one config dir per account (the CCS approach), keep the one
~/.claude every machine already has and swap which broker credential fills
it. A target whose cred is the literal "@active" follows the account named
in activeFile (default ~/.config/ccbroker/active), which
ccb use <name> writes before syncing immediately:
"targets": [ { "cred": "@active", "type": "file", "path": "~/.claude/.credentials.json" } ]ccb use personal # ~/.claude now authenticates as "personal"
ccb use work # ...now as "work"The periodic run loop keeps whatever is currently active fresh, and every
refresh still happens only on the broker. A Claude Code session already running
under the old account holds that account's token in memory, so it keeps using it
until the token expires (within ~8h); at that point the session may briefly show
"Not logged in", and resuming adopts the newly active account from disk
automatically — no /login needed. On machines running autonomous work, prefer
switching between sessions rather than during one so the switch never interrupts
a live run.
The broker polls GET api.anthropic.com/api/oauth/usage for every credential
(the endpoint reports 5-hour / 7-day / per-model-weekly utilization without
consuming message quota) and serves the snapshots at /v1/usage. ccb status
renders them; ccb auto, and pull/run when a rotation policy is active, use
them to pick the active account.
The rotation policy decides which quota windows can trigger an auto-switch:
| Policy | Switches the active account when… |
|---|---|
manual |
never — you switch yourself with ccb use <name> |
account |
the account-wide 5-hour or 7-day window reaches autoThreshold (default 0.95) |
all |
any of the above or any per-model weekly bucket reaches the threshold |
The per-model trap. The account-wide 5h/7d windows can look healthy while a
single model's weekly bucket is already exhausted — top-tier models often carry
their own weekly limit. account ignores model buckets by design: a spent
per-model bucket does not block your other models, so rotating away from an
otherwise-fine account would waste its remaining capacity. Choose all only if
your workflow depends on one specific model and you would rather switch accounts
the moment that model's weekly bucket runs out.
Change the policy any time:
ccb policy # show the effective policy and where it came from
ccb policy all # set it (manual | account | all)or run /ccb-policy in the Claude Code plugin, or edit autoPolicy in
agent.json. The legacy "auto": true flag still works and is equivalent to
account.
ccb statusline prints a one-line summary of the active account from the
cached snapshot (no network in the hot path):
personal 5h:16% 7d:62%
ccb statusline --all renders every account your token can read on one
line — 5h / 7d and each per-model weekly bucket, each followed by a dim ↻
countdown to its own reset, each utilization colored by how full it is and the whole line
suffixed ~stale when the cache is old. The active account comes first,
marked ⛁; the rest follow with the most quota left first (so the best account
to switch to sits next to it), and dead ones (marked ✗) or accounts with no
usage data sort last (shown here without ANSI color):
⛁ work 5h:3%↻4h10m 7d:22%↻6d18h F:9%↻6d18h │ personal 5h:12%↻1h5m 7d:40%↻2d3h F:71%↻2d3h │ spare 5h:0% 7d:64%↻1d4h F:100%↻1d4h
Turn that full line on or off as your Claude Code statusline. Both are idempotent — running either twice leaves the file byte-for-byte unchanged:
ccb statusline on # ~/.claude/settings.json
ccb statusline on --settings ~/.claude-work/settings.json
ccb statusline off # remove it againon writes ccb statusline --all as the statusLine of a settings file that
has none yet; if a statusLine already exists it instead appends a ccbroker
marker block to the statusline script that command points at (preserving the
script's mode), leaving your own statusline intact. off removes exactly
whatever on added. The legacy ccb statusline --install still works — it
writes the statusLine into ~/.claude/settings.json and refuses to overwrite
an existing one:
ccb statusline --install # writes statusLine into ~/.claude/settings.json
ccb statusline --install --settings ~/.claude-work/settings.jsonclaude-plugin/ is a minimal Claude Code plugin exposing /ccb-status,
/ccb-use <name>, /ccb-auto, /ccb-policy [manual|account|all] and
/ccbroker:statusline [on|off] as slash commands, plus one SessionStart hook:
scripts/ensure-ccb.sh (keeps the binary at the plugin's version, below). The
plugin does not sync credentials — the watch daemon and its watchdog do, session
or no session. Claude Code does not render statuslines from a plugin, so
/ccbroker:statusline just shells out to ccb statusline on|off to wire the
line into your settings.json.
Install it from the marketplace:
/plugin marketplace add Dev-Jahn/jahns-cc-marketplace
/plugin install ccbroker
The version in claude-plugin/.claude-plugin/plugin.json names both the plugin
and the ccbroker release whose ccb belongs with it, so updating the plugin
updates the binary — on every machine, without touching any of them.
At every session start scripts/ensure-ccb.sh compares that version with what
~/.config/ccbroker/bin/ccb version reports. Equal (the normal case) and it
exits immediately — no network, no output. Different and it downloads
ccb_<os>_<arch> for the matching release tag, verifies it against the release's
checksums.txt, swaps it into ~/.config/ccbroker/bin/ccb with an atomic
rename, and restarts the watch daemon (launchd com.ccbroker.watch,
systemd-user ccb-watch, or kill + ccb ensure-alive on a cron/manual host) so
it runs the new code instead of the replaced inode. Every action is appended to
~/.config/ccbroker/selfupdate.log.
It never fails a session: a download failure prints one warning line and exits 0,
leaving the working binary alone; a checksum mismatch refuses the install. If the
ccb on your PATH turns out to be a separate binary rather than a symlink to
the managed one, it says so and prints the ln -sf that converges it — that copy
would otherwise stay behind forever.
The plugin also ships bin/ccb, a shim that execs the managed binary. Claude
Code puts a plugin's bin/ on PATH for Bash tool calls only (not for hooks, the
statusline or MCP servers), so it is a convenience for ccb … in a Bash tool
call on a machine where ccb is not on the system PATH — never the mechanism the
daemon or the statusline depends on. Nothing long-lived may point into the
plugin directory: its path carries the plugin version and is swept a couple of
weeks after each update.
If you prefer CCS-style separate profiles, the broker stays format-agnostic:
map credential names to each profile's CLAUDE_CONFIG_DIR:
"targets": [
{ "cred": "personal", "type": "file", "path": "~/.claude/.credentials.json" },
{ "cred": "work", "type": "file", "path": "~/.claude-work/.credentials.json" }
]CCS keeps switching profiles; the agent keeps each profile's token fresh.
To reach the broker from machines that can't share a tunnel (e.g. a tagged
Tailscale host), terminate TLS on a reverse proxy at your own domain and require
a client certificate there — the proxy rejects anyone without a cert before the
request ever reaches the broker, and the app-layer bearer + scope still apply.
Because TLS terminates on your own proxy, no third party sees the credential
bodies. The agent presents its cert via clientCertPath / clientKeyPath:
{ "brokerUrl": "https://ccbroker.example.com",
"token": "…", "clientCertPath": "~/.config/ccbroker/pki/host.crt",
"clientKeyPath": "~/.config/ccbroker/pki/host.key", "targets": [ … ] }nginx (e.g. via Nginx Proxy Manager's advanced config), trusting your client CA:
ssl_client_certificate /data/custom_ssl/ccbroker-client-ca.pem;
ssl_verify_client on;Keep the admin API off the proxy — it stays localhost-only on the broker host.
- Private by default. Meant to run behind a private tunnel (Tailscale/WireGuard); the credential API is then only as reachable as your tunnel. If you must expose it publicly, put it behind a reverse proxy that enforces mTLS so the bearer token is never the only thing between the internet and your credentials (see the reverse-proxy section above).
- Encrypted at rest. The store is AES-256-GCM with a 32-byte master key kept
in a separate
0600file. - Per-client bearer tokens, hashed at rest. The config stores only
sha256(token); comparison is constant-time. Each client has scopes limiting which credential names it may read. Every access is written to an audit log. - Admin API is localhost-only and separately authenticated.
- Canonical sources. The only official sources for
ccbrokerbinaries are this repository and its GitHub Releases (whichinstall.sh/install-server.shand the plugin'sensure-ccb.shupdater all verify againstchecksums.txtbefore installing anything). Do not run binaries for this tool from anywhere else.
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /healthz |
none | liveness |
| GET | /v1/credentials/{name} |
Authorization: Bearer <token> + scope |
current credential for name (refresh token stripped) + {gen, account} envelope; ?probe=1 adds a fresh probeLive; ?sinceGen=G&waitSec=W long-polls (304 on timeout) |
| POST | /v1/creds/offer |
Authorization: Bearer <token> |
offer a local /login credential for account-routed adoption |
| GET | /v1/usage |
Authorization: Bearer <token> |
quota snapshots for all creds in scope (no tokens) |
| PUT | /admin/creds/{name} |
X-Admin-Token (localhost) |
import/replace a credential (verifies live + records account) |
| GET | /admin/creds |
X-Admin-Token (localhost) |
list (redacted) |
| DELETE | /admin/creds/{name} |
X-Admin-Token (localhost) |
remove |
| POST | /admin/creds/{name}/refresh |
X-Admin-Token (localhost) |
force refresh now |
The credential endpoint strips the refresh token before serving (unless
serveRefreshToken is set during the rollout window — see
Upgrading to v0.4.0). It never
returns an already-expired token; it serves 409 for a suspect/dead
credential and 503 for an expired one, so the client keeps its last good copy.
v0.4.0 closes the multi-refresher logout class: two machines sharing one OAuth lineage could each legally refresh (any copy whose token is expired-per-clock refreshes per Claude Code's own rules), revoking the broker's generation and, on a replay of a superseded refresh token, killing the lineage. The fix makes the broker the single writer of the refresh chain.
What changes
- Refresh-token quarantine (invariant I1). Refresh tokens now live only in
the broker store. The broker strips the
refreshTokenwhen it serves a credential, and the client strips it again on every write — so no client disk ever holds a refresh token in the steady state, and no client can refresh (Claude Code with a refresh-token-less credential simply never refreshes; it recovers when the broker updates the token). The one transient exception is a machine that just ran/login: it holds the new lineage's refresh token untilccb syncoffers it upstream — and that token is never destroyed locally until the broker provably holds a live, same-account credential. - Account-routed offer/adopt.
ccb sync(andccb use, and every watch cycle) offer any local/logincredential toPOST /v1/creds/offer. The broker verifies the token is live, routes it to the matching credential by account (not by name), checks an anti-rollback ring, and adopts it. A fresh/logintherefore enters the system through the front door instead of clobbering a managed credential. - Health lifecycle. An access token that returns a confirmed 401/403-revoked
while still unexpired marks the credential
suspect; the broker serves409(so it stops handing out a revoked token within ~5 min instead of ~3 h) and does not immediately replay the refresh token. Recovery is either an incoming/loginoffer or, after 10 min with no offer, a single last-resort reclaim refresh. Adeadcredential is re-probed every 30 min and self-heals on a 200. ccb syncis the new name forccb pull(the old name still works). The overwrite gate never destroys a local refresh token on any failure path (broker down, old broker, rate limit, verify outage, clock skew): the worst case keeps the local credential and retries next cycle.
Rollout order — do this exactly (matches design §5)
- Release and deploy the broker with
"serveRefreshToken": trueinconfig.json. This keeps full backward compatibility: pre-v0.4 clients still receive a refresh token and self-heal; nothing is quarantined yet. - Upgrade all clients to v0.4.0 and convert their schedulers to the watch
daemon (
ccb setupre-run, or installccb watch). Verify the offer path with a real/login, and that the overwrite gate keeps the local credential on a rejected offer.- N-7 warning: during this window, do not run
/loginon a machine that has not yet been upgraded — its old one-waypullwill still clobber a fresh login. Keep the window short.
- N-7 warning: during this window, do not run
- Flip the broker to
"serveRefreshToken": falseand runccb synconce everywhere. Fleet disks lose their refresh tokens and the quarantine is active.
Watch daemon is required (M-6). Quarantine means only the broker refreshes,
so every rotation must reach each machine promptly. ccb setup installs a
watch daemon by default (launchd KeepAlive on macOS, a systemd-user service
with Restart=always on Linux, or a */5 ensure-alive cron wrapper elsewhere),
which long-polls the broker and syncs within seconds of a rotation. It also
installs a periodic ccb sync watchdog: if the daemon dies, any outage is
bounded to one sync interval per rotation (default 30 min) rather than being
open-ended. On a Linux box that should keep syncing while logged out, run
sudo loginctl enable-linger $USER.
Unknown accounts. If ccb sync finds a local credential for an account the
broker does not manage at all, it does not touch it — it keeps the local
credential and warns loudly: that account is a multi-refresher hazard sitting
unmanaged on disk. Either log out of it, or import it into the broker
(PUT /admin/creds/{name}) so the broker becomes its single refresher. Note the
v0.4.0 import verifies the token live and records the account before storing, so
import requires the profile endpoint to be reachable.
- Terms of service. This uses a subscription OAuth token outside the official Claude Code client. Intended for personal, self-hosted use with your own account. Understand your provider's terms before using it.
- Offline windows. The broker rotates a credential's refresh token
refreshSkewSecbefore its access token expires, so every machine must pull the rotated token while its old access token is still valid. Keep each agent'sintervalSec(and any cron cadence) well under the broker'srefreshSkewSec(default 1 h; the default pairing is a 30 min pull vs a 1 h skew). A machine that stays offline past that window can fall back to a local refresh, rotating the broker's refresh token out from under it and forcing a re-login. - Master key. If the key file is lost the store cannot be decrypted; the only cost is re-importing credentials (re-login), but back the key up somewhere safe.
MIT — see LICENSE.