Skip to content
bizurk
← ALL WRITING

2026-05-13 / 15 MIN READ

Credential management for multiple clients on macOS

A working credential management system for multiple clients on macOS. Keychain naming, env file rules, scoped API tokens, and rotation cadence.

The credential surface for a solo operator is asymmetric. One person, four to seven concurrent clients, dozens of tokens scoped across Cloudflare, GitHub, Supabase, Stripe, Resend, AWS, OpenAI, Anthropic, plus whatever the client's stack adds. A single laptop owns all of it. If that laptop is compromised tomorrow, the cleanup spans every client at once. The hygiene system below is what I run on macOS to keep that blast radius bounded without making my own day-to-day painful.

The threat model nobody writes down

Most operators secure their machine the way a salaried employee does: full-disk encryption on, screen lock at five minutes, password manager for logins, 2FA on the important stuff. That posture is fine when your blast radius is one company. It is not fine when your laptop is the operator seat for a fractional practice. The blast radius is multiplicative across clients, not additive across accounts.

Three failure modes have shown up in my own practice or the practices of operators I have helped. The first is a lost or stolen device with a saved authenticated session for a client's production console. The second is a token that was scoped wrong on creation. Account-wide when it should have been zone-wide. Read-write when it should have been read-only. The third is a token that was scoped correctly but never rotated, and is now floating in some old shell history file or a Lambda environment variable nobody remembers setting.

The system below assumes all three of those will eventually happen. The point is to make each one a small, contained event instead of a multi-client incident.

I run macOS Keychain as the cross-account secret store rather than a third-party vault. Two reasons. The first is that Keychain is the operating system's native secret store, which means Touch ID, file vault, and the system's own security model already cover it. There is no extra trust surface for a third-party vendor. The second is that the security command-line tool is scriptable, which matters when AI coding agents need to retrieve credentials on demand without a clipboard ceremony. A 1Password CLI works for this too, but Keychain is already there and I have not found a reason to add a layer.

The two-tier split: global vs project-scoped secrets

Every credential I hold falls into one of two tiers, and the tier determines where it lives.

The global tier holds tokens whose blast radius spans multiple clients or projects. A Cloudflare API token that can edit DNS for any zone I am invited to. A Supabase Personal Access Token that can manage projects across orgs via the Management API. An OpenAI organization key that bills my account regardless of which project pulled it. These tokens belong in Keychain, not in any project's .env.local. A junior agent or a misconfigured logger that tails a project's environment should never be able to see them.

The project-scoped tier holds tokens whose blast radius is the project itself. A Supabase anon key for one specific project. A Stripe restricted key for one specific account. A Resend API key for one client's transactional sending domain. These belong in .env.local, gitignored, mirrored to the project's hosting platform's environment variable store (Vercel, Cloudflare Pages, Fly.io, whatever).

The boundary rule is simple: if a token can affect anything outside this project's directory, it does not live in this project's directory. That rule alone removes most of the credential leaks I have seen in operator codebases.

macOS Keychain as the global secret store

Open Keychain Access on macOS. The default keychain is login, which is unlocked when you log in and re-locked on screensaver. That is the right place for the global tier. iCloud Keychain syncs across your devices if you want that; for an operator with one work machine and one phone, this is mostly a convenience, not a security risk.

The security CLI gives you scriptable read access. Two commands cover almost every case:

# Add a new generic password entry
security add-generic-password \
  -a default \
  -s cloudflare-api-token \
  -w "$TOKEN_VALUE" \
  -U

# Read it back
security find-generic-password \
  -a default \
  -s cloudflare-api-token \
  -w

The account: default convention is the part most operators get wrong. Keychain entries have two indexable fields, account (-a) and service (-s). If you tag every cross-project token with account default, any agent in any working directory can resolve it with the same command. No project-specific naming, no per-shell environment lookup table.

A worked subset of my own service names:

account: default
  service: cloudflare-api-token       # Zone:DNS:Edit, scoped per zone where I work
  service: supabase-access-token      # PAT for Management API across orgs I belong to
  service: github-fine-grained-pat    # Read-write to specific repos, two-week expiry
  service: openai-api-key-personal    # My own org, not any client's
  service: anthropic-api-key-personal # Same
  service: vercel-token               # Account-wide deploys

For client-tagged credentials that still need to be globally available (like a contractor token a client gave me to use across several of their repos), I use a project-prefixed account instead of default:

account: client-acme
  service: github-org-token
  service: cloudflare-zone-token

That keeps the discovery pattern consistent. An agent working in the Acme repo retrieves with -a client-acme. An agent doing cross-project DevOps retrieves with -a default. Two namespaces, predictable enough that I do not have to think about which one to use.

I do not put any of these in .env.local. Ever. The retrieval pattern in a setup script is one extra line, which is cheaper than the cost of an accidentally-committed env file with a Cloudflare token in it.

# In a setup script, not in .env.local
export CLOUDFLARE_API_TOKEN=$(security find-generic-password -a default -s cloudflare-api-token -w)
export SUPABASE_ACCESS_TOKEN=$(security find-generic-password -a default -s supabase-access-token -w)

What goes in .env.local and what does not

The litmus test for .env.local is a single question: if this file is leaked, what is the worst case? If the answer is "this one project's data," it can live in the file. If the answer crosses a project boundary, the credential is in the wrong place.

What stays in .env.local:

  • NEXT_PUBLIC_* variables that ship to the browser anyway
  • Project-scoped Supabase URL and anon key
  • Project-scoped Stripe keys (restricted, scoped to the events this project actually sends)
  • Project-scoped Resend keys with one sending domain
  • Local development database URLs

What stays out of .env.local:

  • Cloudflare API tokens that can hit any zone
  • Supabase Personal Access Tokens
  • GitHub PATs or fine-grained tokens that span multiple repos
  • AWS root or IAM keys with broad permissions
  • LLM API keys that bill across projects
  • Stripe secret keys with full account access (use restricted keys instead)

The verification step is one line:

git check-ignore -v .env.local && echo "ignored" || echo "TRACKED - FIX NOW"

Run that the moment a project is cloned. I have seen .env.local accidentally committed in starter repos because the template .gitignore did not list it. The fix is two seconds. The leak is forever.

The blast radius for a fractional operator is multiplicative across clients, not additive across accounts.

Cloudflare API token scoping that does not over-grant

Cloudflare's default token UX nudges you toward broad scope. The "Edit zone DNS" template is account-wide. That template is wrong for almost every fractional engagement. The right token is per-zone, per-permission.

Create a custom token. Permissions: Zone:DNS:Edit. Zone resources: include only the specific zone you are working on, not "all zones from an account." If you work on three of a client's six zones, that is three tokens, not one.

The scoping pays off at offboarding. When a client engagement ends, you revoke three tokens that touched three zones. The other three zones, which you never had access to, are unaffected. The audit trail on the client's side shows clean revocations against specific resources, which is exactly the posture that keeps a GC happy.

Rotation is the other half. Cloudflare lets you set token expiry; I default to 90 days for active engagements and revoke on the day a retainer ends regardless of expiry. The token's name in Cloudflare follows a convention I keep consistent: <initials>-<zone>-<permission>-<created-yyyy-mm>. Reading that name in the audit log answers every question someone will ask in a security review.

GitHub fine-grained tokens replace classic personal access tokens

The classic GitHub PAT is account-wide and all-repos. It was the only option for years. It is the wrong tool for fractional work. A classic PAT issued to access one client's private repo can also read any of your other clients' repos that you have access to via your personal GitHub account. The blast radius is the entire set of repos your account can see.

Fine-grained personal access tokens fix this. They are scoped per repository (or per organization with explicit repo selection) and per permission (contents read vs read-write, pull requests, secrets, environments). The expiry is enforced; the maximum is one year, and the recommended default is 30 days for active work.

A working pattern for one client's repo:

  • Resource owner: client's GitHub org (requires the org to have approved fine-grained tokens)
  • Repository access: only the specific repos in scope for the engagement
  • Permissions: contents read-write, pull requests read-write, metadata read; nothing else
  • Expiry: 30 days, refresh on the team's monthly retro

Fine-grained tokens require organizations to opt in. About half of the orgs I have worked with had not when I asked. The onboarding conversation is short: send them GitHub's docs, explain that the alternative is a classic PAT with cross-org reach, and they usually agree the same day.

For orgs that have not opted in, the fallback is a GitHub App or a SSH deploy key on the specific repo. Both are scoped tighter than a classic PAT. A classic PAT to a third-party org should be the last resort, never the first.

Rotation cadence that actually happens

The rotation policy that works for me is event-driven, not calendar-driven. A quarterly reminder will get snoozed. A trigger list will not.

The triggers:

  • A retainer ends or a project closes
  • A laptop or phone is lost or replaced
  • A token's expiry warning fires (most platforms email at 7 days)
  • A subprocessor in a client's stack rotates their own credentials and asks vendors to rotate
  • A weird login alert hits, even if it turned out to be me on a VPN

The rotation order matters. Revoke first, then regenerate, then update consumers, then verify. That sequence is uncomfortable because it briefly takes the integration offline, but it is correct. The reverse order leaves a window where both old and new tokens are valid; if the old one was the leaked one, the leak is still active during that window.

I keep a credential inventory file in the practice OS. It holds metadata, not the credentials themselves: a list of where each credential type lives, what it touches, and when it was last rotated. The inventory is a markdown file in a private repo, gitignored from any client work. It looks like a CSV of tuples: token name, Keychain service, scope, last rotated, next planned rotation, notes. The whole point of the inventory is that on the day a laptop walks away, I can sit down at a backup machine and revoke everything in 20 minutes flat.

When a client offboards, what changes in 24 hours

The offboarding checklist is the test of whether the hygiene system is real. If you cannot finish it in a focused afternoon, the system has drift.

On the day a retainer ends:

  • Revoke all client-scoped Cloudflare tokens, named per the convention so you find them by prefix
  • Revoke all GitHub fine-grained tokens against the client's repos and orgs
  • Delete client-tagged Keychain entries (-a client-acme); the global ones stay
  • Remove the client's Supabase project from your local CLI projects list if you used the Management API
  • Audit ~/.aws/credentials and ~/.config/-adjacent files for any client-scoped profiles; remove them
  • Search shell history (grep -i client-name ~/.zsh_history) for any pasted secrets and rotate any that turn up
  • Email the client a closing memo that lists what you revoked, on what date, and confirms read-only access has ended

The closing memo lives in the engagement's archive. If the client ever asks "what did Michael have access to and when did it stop," the answer is one document.

Some Keychain entries are kept on purpose. Read-only audit credentials, if both parties signed off on a 12-month lookback window, can stay until the lookback expires. Everything else goes.

That checklist closes the loop on the system. The work that built it sits one layer up in the practice operating system I run across the practice, and the engagement shapes that triggered each credential pattern are catalogued in the four fractional engagement shapes I sell. For operators on the creative-tech side of the workspace, the solo brand hub for a one-person creative tech practice frames where this sits in the broader stack, and the cost of context switching across multiple client accounts explains why getting the credential layer right also reduces the per-client cognitive tax. Operators who need a worked example of credential mistakes biting in production can see the AWS bill I audited down in a weekend, where credential architecture was the second of two failure modes; the underlying silent failure incident is in the case study archive. Operators who want this entire system pre-built, including the inventory file and the offboarding checklist, can find it inside the Operator's Stack.

FAQ

Why not just use 1Password CLI for everything instead of macOS Keychain?

1Password CLI is a fine choice and plenty of operators use it well. I prefer Keychain for the cross-account global tier because it is the OS native store and Touch ID gates retrieval automatically. For team-shared client credentials, a 1Password vault per client makes sense. I treat them as complementary: 1Password for client-shared and identity items, Keychain for the operator's own cross-account API tokens.

What about putting credentials in environment variables in my shell profile?

Shell profiles like .zshrc or .envrc end up in dotfile repos, terminal recordings, screen shares, and asciinema casts. Putting a Cloudflare API token in .zshrc is a worse pattern than putting it in .env.local. Use Keychain plus a security find-generic-password lookup at the moment you need the value, not a persistent shell variable.

How do you give an AI coding agent access to a credential without leaking it to model logs?

The agent runs the security find-generic-password command itself, in your shell, on your machine. The decrypted value is exported as an environment variable for the duration of the process and is not part of the prompt. The model sees the command, not the secret. If the model later writes that env variable into a log file or commits it, that is a different problem solved by linting and pre-commit hooks. The key is not handing the model the literal token in a chat turn.

Is iCloud Keychain sync a security risk for global tokens?

iCloud Keychain is end-to-end encrypted with the user's iCloud Security Code, and Apple does not have access to the contents. For most operators it is a reasonable trade: the convenience of having the token available on a backup laptop outweighs the marginal risk of sync. The credentials I keep out of iCloud sync are the few that explicitly forbid cloud sync in their terms (some banking and some enterprise tokens). Everything else syncs.

What is the right rotation interval for fractional credentials?

Event-driven beats calendar-driven. The triggers I use are retainer end, device change, expiry warning, vendor-side rotation, and weird login alert. A 90-day calendar reminder is a fine secondary safety net, but if you only rotate on the calendar, you will miss the events that actually matter.

Sources and specifics

  • The security command-line tool is the macOS native interface to Keychain; the find-generic-password and add-generic-password subcommands are the read/write surface used in this article.
  • The account: default convention is the cross-project naming pattern documented in my own global Claude Code instructions and used live across the workspace.
  • Cloudflare's API token system supports per-zone, per-permission scoping; the Zone:DNS:Edit token template is documented in Cloudflare's API tokens UI.
  • GitHub fine-grained personal access tokens were promoted to general availability and support per-repo scoping with a maximum one-year expiry; per-org opt-in is required for tokens that target an organization's repositories.
  • The rotation cadence and offboarding checklist are the working procedures I run across concurrent fractional engagements as of Q2 2026, not a vendor-published recommendation.

// related

Let us talk

If something in here connected, feel free to reach out. No pitch deck, no intake form. Just a direct conversation.

>Get in touch