Skip to content

[wrangler] Add opt-in OS keychain storage for OAuth credentials#14156

Open
petebacondarwin wants to merge 4 commits into
mainfrom
feat/keyring-credential-storage
Open

[wrangler] Add opt-in OS keychain storage for OAuth credentials#14156
petebacondarwin wants to merge 4 commits into
mainfrom
feat/keyring-credential-storage

Conversation

@petebacondarwin
Copy link
Copy Markdown
Contributor

@petebacondarwin petebacondarwin commented Jun 2, 2026

Fixes #14099.

Adds an opt-in path for storing wrangler's OAuth credentials in an AES-256-GCM-encrypted file under the wrangler config directory, with the encryption key held in the OS keyring (macOS Keychain, libsecret on Linux, or Windows Credential Manager). The default behavior is unchanged — existing users keep using the plaintext file. Opting in is explicit and persisted across invocations.

Why encrypted-file-with-keyring-key?

Storing credentials directly in the OS keyring runs into per-platform item-size limits (notably the ~2.5 KB macOS Keychain kSecAttrGeneric limit), which would block adding richer auth state in the future. Storing only the encryption key (~44 bytes) sidesteps the limit while still pinning at-rest confidentiality to a hardware-/login-protected secret store.

Usage

  • wrangler login --use-keyring — opt in; persisted to <wrangler-config>/preferences.json.
  • wrangler login --no-use-keyring — opt back out. Deletes the encrypted file and the keyring entry; the subsequent login writes fresh credentials to the plaintext TOML. Opt-out never decrypts existing credentials onto disk (that would defeat the at-rest protection the user is choosing to disable).
  • CLOUDFLARE_AUTH_USE_KEYRING=true|false — per-process override.
  • wrangler whoami — reports where credentials are stored.

Per-platform backends

Wrangler ships with zero native credential dependencies. Each platform uses whatever is already there:

Platform Key storage Install cost on opt-in
macOS /usr/bin/security (built-in) 0 KB
Linux secret-tool from libsecret-tools 0 KB (per-distro install hint if missing)
Windows @napi-rs/keyring lazy-installed via npm install on first opt-in ~1.9 MB one-time download
Other (FreeBSD, etc.) n/a — falls back to file with a warning, or hard-errors when CLOUDFLARE_AUTH_USE_KEYRING=true

Non-opt-in users (the majority) pay zero install cost. macOS and Linux opt-in users pay zero install cost too. Only Windows opt-in users ever fetch a native binary, and only on first opt-in.

Encryption details

  • Algorithm: AES-256-GCM via node:crypto — no third-party crypto deps.
  • File location: <wrangler-config>/config/<env>.enc, sibling of the legacy <env>.toml so opt-in migration is non-destructive.
  • On-disk format: JSON envelope { v: 1, alg: "AES-256-GCM", iv, tag, ciphertext } (all binary fields base64). The GCM auth tag detects tampering; a mismatch is treated as "not logged in" and the next login re-encrypts.
  • Keyring entry: JSON envelope { v: 1, key, created } holding only the 32-byte symmetric key — well below the macOS Keychain ~2.5 KB per-item limit.
  • IV/nonce: 12 bytes, freshly generated per write.

Migration

  • First write after opt-in with an existing plaintext TOML: read the legacy file, generate a fresh key, encrypt and write the .enc file, delete the plaintext file. Logged as Migrated credentials from <toml> into <enc> (key in <keyProvider>)..
  • --no-use-keyring opt-out: delete the encrypted file and the keyring entry without decrypting them. Log Removed the encrypted credentials and the keyring entry. Run \wrangler login` to log in again.. The user then runs wrangler login` (or the same opt-out command continues into login) which writes fresh credentials into the plaintext TOML.
  • Lost key (file present, keyring entry missing): treated as "not logged in"; next login regenerates the key.
  • Corrupted file (auth tag mismatch): treated as "not logged in"; same recovery.

Internal architecture

All credential persistence — file backend, encrypted-file backend, key providers, and resolver — lives in @cloudflare/workers-auth. createOAuthFlow(ctx) now requires a credentialStorage block and returns a new getCredentialStore() accessor for whoami-style code:

const oauthFlow = createOAuthFlow({
  logger,
  isNonInteractiveOrCI,
  // ... existing fields
  credentialStorage: {
    serviceName: "wrangler",       // consumer-provided; future CLIs use their own
    isKeyringEnabled: () => readUserPreferences().keyring_enabled === true,
    cliName: "wrangler",            // for error-message templating
  },
});

oauthFlow.getCredentialStore().describe();
// "Encrypted file (<wrangler-config>/config/default.enc) with key in macOS Keychain (service=wrangler, account=default)"

Future Cloudflare CLIs that reuse @cloudflare/workers-auth get OS keyring–encrypted credential storage by providing their own serviceName.

CLOUDFLARE_API_TOKEN and CLOUDFLARE_API_KEY/CLOUDFLARE_EMAIL continue to take priority over any stored OAuth credentials.

Tests

  • workers-auth (new package tests, 110 tests across 7 files):
    • credential-store/crypto.test.ts (19) — round-trip, tampered ciphertext, wrong key, IV non-repetition, envelope version checks
    • credential-store/file-store.test.ts (9) — plaintext TOML round-trip, missing/corrupted file handling
    • credential-store/encrypted-file-store.test.ts (18) — encrypt/decrypt, missing key, file-tampered, legacy plaintext→encrypted migration on first read
    • credential-store/resolver.test.ts (21) — full platform matrix (darwin / linux-with-secret-tool / linux-missing / win32-installed / win32-missing-interactive / win32-missing-non-interactive / unsupported / forced)
    • credential-store/key-providers/mac-security.test.ts (12), linux-secret-tool.test.ts (13), lazy-installer.test.ts (9) — argv construction, exit-code handling, stdin-based secret writes, WRANGLER_API_ENVIRONMENT scoping
  • wrangler: --use-keyring / --no-use-keyring flag tests rewritten to use the workers-auth setKeyProviderFactoryForTesting seam. The opt-out test now seeds encrypted credentials, runs --no-use-keyring, and asserts that the encrypted file + keyring entry are gone AND the stale credentials do not appear in the new plaintext file. Keyring-aware logout test updated. Full wrangler suite: 4300 passed, 4 skipped, 9 todo across 246 test files.
  • All previous passes preserved: pnpm check:lint, pnpm check:type, pnpm prettify, pnpm check, both packages' test:ci.

Files

  • Added to @cloudflare/workers-auth:
    • src/credential-store/{interface,file-store,encrypted-file-store,crypto,resolver,state,index}.ts
    • src/credential-store/key-providers/{interface,shared,factory,mac-security,linux-secret-tool,napi-keyring,lazy-installer}.ts
    • 7 test files under tests/credential-store/
  • Modified in @cloudflare/workers-auth: auth-config-file.ts (slimmed; now delegates), flow.ts (validates + populates state + adds getCredentialStore accessor), context.ts (adds credentialStorage block), env-vars.ts (adds CLOUDFLARE_AUTH_USE_KEYRING), index.ts (exports new APIs), AGENTS.md (documents credential storage layer)
  • Removed from wrangler: src/user/{credential-store,mac-security-store,linux-secret-tool-store,lazy-keyring-installer,keyring-shared}.ts and their 4 test files
  • Added to wrangler: src/user/preferences.ts (the keyring_enabled user preference)
  • Modified in wrangler: user.ts (adds credentialStorage block to createOAuthFlow, exports getCredentialStore), whoami.ts (uses getCredentialStore), commands.ts (--use-keyring opts in; --no-use-keyring scrubs encrypted credentials), user.test.ts + logout.test.ts (use workers-auth test seam)

Opening as draft — the companion Cloudflare docs PR (cloudflare/cloudflare-docs#31211) is linked above. Ready to move out of draft once both PRs are reviewed.

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Jun 2, 2026

🦋 Changeset detected

Latest commit: 81bc660

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 5 packages
Name Type
wrangler Minor
@cloudflare/workers-auth Minor
@cloudflare/vite-plugin Patch
@cloudflare/vitest-pool-workers Patch
@cloudflare/wrangler-bundler Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 2, 2026

✅ All changesets look good

ask-bonk[bot]

This comment was marked as resolved.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Jun 2, 2026

Review posted successfully to PR #14156.

Summary of the review I submitted:

  1. (high) credential-store.ts:372 — the keyring-unavailable fallback warning tells users to run wrangler login --no-keyring, but that flag doesn't exist. The actual flag is --no-use-keyring. Posted a suggestion fix.
  2. (medium) credential-store.ts:360probeKeyring() does a real keychain write+delete and runs on every getActiveCredentialStore() resolution (called repeatedly per command via readStoredAuthState), so it's uncached and will hammer the OS keychain / risk macOS access prompts. Recommended memoizing the probe result per process.
  3. (low) preferences.ts:16 — stale comment referencing the same wrong --no-keyring flag name. Posted a suggestion fix.

I also verified that the @napi-rs/keyring dependency concern (regular dep vs optional) is mitigated, because the platform-specific native binaries are themselves optionalDependencies of the package and loading is lazy/guarded, so install won't break on unsupported platforms.

github run

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Jun 2, 2026

create-cloudflare

npm i https://pkg.pr.new/cloudflare/workers-sdk/create-cloudflare@14156

@cloudflare/deploy-helpers

npm i https://pkg.pr.new/cloudflare/workers-sdk/@cloudflare/deploy-helpers@14156

@cloudflare/kv-asset-handler

npm i https://pkg.pr.new/cloudflare/workers-sdk/@cloudflare/kv-asset-handler@14156

miniflare

npm i https://pkg.pr.new/cloudflare/workers-sdk/miniflare@14156

@cloudflare/pages-shared

npm i https://pkg.pr.new/cloudflare/workers-sdk/@cloudflare/pages-shared@14156

@cloudflare/unenv-preset

npm i https://pkg.pr.new/cloudflare/workers-sdk/@cloudflare/unenv-preset@14156

@cloudflare/vite-plugin

npm i https://pkg.pr.new/cloudflare/workers-sdk/@cloudflare/vite-plugin@14156

@cloudflare/vitest-pool-workers

npm i https://pkg.pr.new/cloudflare/workers-sdk/@cloudflare/vitest-pool-workers@14156

@cloudflare/workers-auth

npm i https://pkg.pr.new/cloudflare/workers-sdk/@cloudflare/workers-auth@14156

@cloudflare/workers-editor-shared

npm i https://pkg.pr.new/cloudflare/workers-sdk/@cloudflare/workers-editor-shared@14156

@cloudflare/workers-utils

npm i https://pkg.pr.new/cloudflare/workers-sdk/@cloudflare/workers-utils@14156

wrangler

npm i https://pkg.pr.new/cloudflare/workers-sdk/wrangler@14156

@cloudflare/wrangler-bundler

npm i https://pkg.pr.new/cloudflare/workers-sdk/@cloudflare/wrangler-bundler@14156

commit: 81bc660

@petebacondarwin petebacondarwin force-pushed the feat/keyring-credential-storage branch 4 times, most recently from 373cdbf to 6be1153 Compare June 3, 2026 11:43
@petebacondarwin petebacondarwin marked this pull request as ready for review June 3, 2026 13:00
@workers-devprod workers-devprod requested review from a team and penalosa and removed request for a team June 3, 2026 13:01
@workers-devprod
Copy link
Copy Markdown
Contributor

Codeowners approval required for this PR:

  • @cloudflare/wrangler
Show detailed file reviewers
  • .changeset/wrangler-keyring-credential-storage.md: [@cloudflare/wrangler]
  • packages/workers-auth/AGENTS.md: [@cloudflare/wrangler]
  • packages/workers-auth/src/auth-config-file.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/context.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/crypto.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/encrypted-file-store.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/file-store.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/index.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/interface.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/key-providers/factory.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/key-providers/interface.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/key-providers/lazy-installer.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/key-providers/linux-secret-tool.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/key-providers/mac-security.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/key-providers/napi-keyring.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/key-providers/shared.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/resolver.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/credential-store/state.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/env-vars.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/flow.ts: [@cloudflare/wrangler]
  • packages/workers-auth/src/index.ts: [@cloudflare/wrangler]
  • packages/workers-auth/tests/credential-store/crypto.test.ts: [@cloudflare/wrangler]
  • packages/workers-auth/tests/credential-store/encrypted-file-store.test.ts: [@cloudflare/wrangler]
  • packages/workers-auth/tests/credential-store/file-store.test.ts: [@cloudflare/wrangler]
  • packages/workers-auth/tests/credential-store/key-providers/lazy-installer.test.ts: [@cloudflare/wrangler]
  • packages/workers-auth/tests/credential-store/key-providers/linux-secret-tool.test.ts: [@cloudflare/wrangler]
  • packages/workers-auth/tests/credential-store/key-providers/mac-security.test.ts: [@cloudflare/wrangler]
  • packages/workers-auth/tests/credential-store/resolver.test.ts: [@cloudflare/wrangler]
  • packages/workers-utils/src/environment-variables/factory.ts: [@cloudflare/wrangler]
  • packages/wrangler/package.json: [@cloudflare/wrangler]
  • packages/wrangler/src/tests/logout.test.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/tests/user.test.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/tests/whoami.test.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/user/auth-variables.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/user/commands.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/user/preferences.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/user/user.ts: [@cloudflare/wrangler]
  • packages/wrangler/src/user/whoami.ts: [@cloudflare/wrangler]
  • pnpm-lock.yaml: [@cloudflare/wrangler]

devin-ai-integration[bot]

This comment was marked as resolved.

The opt-out scrub for `wrangler login --no-use-keyring` went through
`getCredentialStore()`, which resolves to `FileCredentialStore` whenever
`CLOUDFLARE_AUTH_USE_KEYRING=false` is set in the environment (the
resolver short-circuits on the env var before consulting the persistent
preference). `FileCredentialStore.delete()` only removes the plaintext
`.toml`, so the `.enc` file and the OS keyring entry were left on disk
after opt-out.

Resolve the encrypted store directly in the opt-out branch so the scrub
always targets the backend the user is opting out of, regardless of the
env-var state. When the keyring backend is unreachable on the current
host, best-effort scrub the `.enc` file and warn that the keyring entry
may still persist (the ciphertext is useless without the key, but
leaving stale files around is confusing).

Adds a regression test that stubs the env var and asserts both the
`.enc` file and the keyring entry are scrubbed.
@petebacondarwin petebacondarwin force-pushed the feat/keyring-credential-storage branch from 1075895 to 81bc660 Compare June 3, 2026 16:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Untriaged

Development

Successfully merging this pull request may close these issues.

Support storing OAuth/refresh tokens in OS keychain instead of plaintext

2 participants