Agent Skills › cipherstash/stack › stash-auth

stash-auth

GitHub

CipherStash认证核心技能,涵盖服务令牌交换、客户端凭据、用户身份及密钥绑定。提供Auto/OIDC/Device等策略配置指南,解决401错误、多用户场景下的JWT缓存与异步上下文问题。

skills/stash-auth/SKILL.md cipherstash/stack

Trigger Scenarios

遇到401或NOT_AUTHENTICATED错误 需要配置OIDC联邦或访问密钥 涉及per-user身份绑定的加密操作 部署凭证生成

Install

npx skills add cipherstash/stack --skill stash-auth -g -y
More Options

Use without installing

npx skills use cipherstash/stack@stash-auth

指定 Agent (Claude Code)

npx skills add cipherstash/stack --skill stash-auth -a claude-code -g -y

安装 repo 全部 skill

npx skills add cipherstash/stack --all -g -y

预览 repo 内 skill

npx skills add cipherstash/stack --list

SKILL.md

Frontmatter
{
    "name": "stash-auth",
    "description": "How a CipherStash client authenticates — the canonical skill for credentials, auth strategies, and lock context. Covers the service-token model (every request to a CipherStash service carries a CTS-minted token; strategies exist to obtain one), the three separable concerns people conflate (client credentials, end-user identity, key binding), the `@cipherstash\/auth` strategies (`AutoStrategy`, `AccessKeyStrategy`, `OidcFederationStrategy`, `DeviceSessionStrategy`) and the Result-unwrap trap on `create()`, the four `CS_*` variables and `stash env`, credential discovery vs explicit config on the native and WASM entries, client lifetime with user-scoped strategies, `.withLockContext` and what is deprecated around it, and CTS failure codes. Use when an operation fails with 401\/NOT_AUTHENTICATED\/WORKSPACE_MISMATCH, when choosing between an access key and OIDC federation, when wiring per-user identity-bound encryption, when minting deployment credentials, or whenever another skill mentions \"credentials\" or `config.authStrategy` — this skill is the canonical source for that model."
}

Authenticating to CipherStash

Every request a Stack client makes to a CipherStash service authenticates with a CipherStash service token: a short-lived signed JWT minted by CTS (the CipherStash token service). That is the only credential ZeroKMS accepts. Access keys and identity-provider JWTs are never sent to ZeroKMS — they are exchanged at CTS for a service token, and everything in this skill is machinery for doing that exchange correctly and automatically.

This skill is canonical for authentication. Where other skills (stash-edge, stash-encryption, stash-cli, stash-deployment) touch credentials in passing and disagree with this one, this one wins. For what a token authorizes — keysets, clients, grants — see stash-zerokms: authentication and keyset access are separate gates, and a perfectly valid token still fails operations on a keyset its client was never granted.

The three concerns (do not merge them)

Concern What it decides Where it's configured
Client credentials Which workspace (CS_WORKSPACE_CRN) and — more granular than the workspace — which ZeroKMS client is acting (CS_CLIENT_ID, CS_CLIENT_KEY) and the access key that authenticates it (CS_CLIENT_ACCESS_KEY) CS_* variables / dev profile / config.accessKey
End-user identity Authenticating the client as a specific user config.authStrategy: OidcFederationStrategy
Key binding (lock context) Who can retrieve a value's data key — binds key access to a claim .withLockContext({ identityClaim }) per operation

Note the granularity in the first row: only the CRN is about the workspace. The other three variables identify a specific client within it — its id, its key material, and its access key — so "the credentials" always means a particular client's identity, not a workspace-wide secret (see stash-zerokms for what a client is and what it can reach).

The classic conflation is between the last two: an auth strategy decides who the client is; a lock context decides who can get a value's data key back. They are orthogonal. Authenticating as a user via OidcFederationStrategy does not by itself bind any value to that user — without .withLockContext(), any client with the right keyset access can retrieve the value's key. Lock context requires OidcFederationStrategy on the Stack surface (there must be a user claim in the service token to bind to), but not the other way around.

The token exchange (CTS)

CTS's authorize endpoint exchanges exactly one of two credential kinds for a service token:

  • A CipherStash access key (CSAK…) — machine identity, for services/CI.
  • An OIDC JWT from an identity provider configured for the workspace (Clerk, Supabase, Auth0, or Okta — or CipherStash's own login) — end-user identity. CTS verifies it against the provider's JWKS and checks workspace membership.

The minted token carries: the subject (CS|<user-id> or CS|CSAK<key-id>), the workspace, scopes derived from the credential's role (member or admin — see stash-zerokms for what the scopes gate), and a built-in service discovery mechanism — the token itself tells the client where the workspace's services (ZeroKMS) live. The workspace CRN (crn:<region>.<provider>:<workspace-id>) names the region, CTS resolves the regional endpoints, and the client reads them from the token. You don't need to know how it works; the one fact that matters is that a CipherStash-issued service token is only valid for services in the region it was issued for — it cannot be replayed against another region. Endpoints are never hand-configured — the CS_*_HOST override variables are debug-only and must not appear in CI, examples, or docs.

CipherStash runs in a variety of regions across the world, and the set is subject to change — stash auth regions (add --json for machine-readable output) lists the current ones; see stash-cli. As of this writing:

Region Location
us-east-1 Virginia, USA
us-east-2 Ohio, USA
us-west-1 California, USA
us-west-2 Oregon, USA
eu-west-1 Dublin, Ireland
eu-central-1 Frankfurt, Germany
ap-southeast-2 Sydney, Australia

A workspace lives in one region, chosen at creation (the stash auth login region picker / --region); the CRN records it thereafter.

CTS failures are deliberate and descriptive: 401 with a reason ("Principal X is not a member of workspace Y", "No OIDC provider found for issuer: …", "Access key was malformed: …"), and 402 ("Insufficient balance…") when the organisation is over its usage limit. A 402 is a billing problem, not a credentials problem — don't rotate keys over it.

The billing refusal, and why it needs its own handling

A token failure is normally transient (network, an expired token about to be renewed) or a credentials problem (wrong key, wrong workspace) — both worth retrying or re-authenticating. A 402 is neither: nothing the process can do clears it. A retry loop that treats every token failure alike will hammer a condition only a human with a billing page can resolve.

So it is separately identifiable. A failure that came from CTS carries authCode alongside the usual type and code, with instructions on help and the link that goes with them on url:

const result = await client.encrypt(value, { column, table })
if (result.failure?.authCode === 'USAGE_LIMIT_EXCEEDED') {
  // Stop retrying. `result.failure.help` says what to do,
  // `result.failure.url` is where to do it.
}

The remedy and link remain structured on help and url; message stays the original diagnosis owned by stack-auth.

Two codes mean "stop", and they need different remedies:

authCode What it means What clears it
USAGE_LIMIT_EXCEEDED The organisation has used its allowance for the current billing period Upgrade the plan at dashboard.cipherstash.com/billing
ORG_NOT_PROVISIONED The organisation isn't registered with the usage system at all Nothing you can buy — contact support

authCode is set for every other CTS failure too (NOT_AUTHENTICATED, WORKSPACE_MISMATCH, EXPIRED_TOKEN, …), and where one of those carries remedy text — MISSING_WORKSPACE_CRN naming CS_WORKSPACE_CRN, say — it now reaches help rather than being dropped. Treat the set as open — it belongs to @cipherstash/auth and grows on its own release train, so compare with === rather than switching exhaustively over it.

Encryption() throws rather than returning a Result, so at client init the same code rides on the thrown error: (err as { authCode?: string }).authCode.

Two of those fields are new alongside authCode. help is the remedy text and url is its destination; both come directly from the upstream diagnostic.

For the two terminal codes, read both. As of stack-auth 0.42.3 CipherStash attaches a help and a url to each. Render both when presenting guidance.

One shape to know: a refusal met by LockContext.identify() is an HTTP response rather than a thrown error, so its failure carries type and authCode but no code — the closed protect-ffi code set does not apply to a direct HTTP call. Branch on authCode there, not code.

On the CLI, stash env reports these under their own codes rather than session_invalid — usage_limit_exceeded and org_not_provisioned — and points at the dashboard or at support instead of telling you to log in again. On --json the remedy arrives in a hint field, not in message. stash auth login --json retains the upstream uppercase spellings USAGE_LIMIT_EXCEEDED and ORG_NOT_PROVISIONED; consumers of both command streams should normalize that casing difference.

The strategies

From @cipherstash/auth, re-exported by @cipherstash/stack so no separate install is needed. Every strategy has one job — getToken() — and handles caching and refresh internally; you never store or refresh a service token yourself.

Strategy Use for Credential source
AutoStrategy (the default when config.authStrategy is unset) Most apps CS_CLIENT_ACCESS_KEY and CS_WORKSPACE_CRN env vars, else the dev profile (~/.cipherstash/auth.json), else fails NOT_AUTHENTICATED
AccessKeyStrategy Services, CI, backfill jobs Explicit workspace CRN + access key
OidcFederationStrategy Per-user (identity-bound) encryption Your IdP's JWT, fetched via a callback on every operation — one CTS token cached per distinct JWT (see Client lifetime)
DeviceSessionStrategy CLI-adjacent tooling The device-code session stash auth login created

auto's access-key arm needs both variables, and the two missing-variable cases behave differently: with CS_CLIENT_ACCESS_KEY unset, detection falls back to the dev profile; with the access key set but CS_WORKSPACE_CRN missing, it fails MISSING_WORKSPACE_CRN rather than silently falling back — a half-configured environment errors instead of quietly authenticating as whatever profile is on the machine.

import { Encryption, AccessKeyStrategy } from "@cipherstash/stack"

// create() returns a Result — UNWRAP IT (see below).
const strategy = AccessKeyStrategy.create(workspaceCrn, accessKey)
if (strategy.failure) {
  throw new Error(`auth: ${strategy.failure.type}: ${strategy.failure.error.message}`)
}

const client = await Encryption({
  schemas: [users],
  config: { authStrategy: strategy.data },
})

For end users, OidcFederationStrategy.create(workspaceCrn, getJwt) — the getJwt callback is invoked on every getToken() (every operation) and must return the IdP JWT of the user behind the current request, not a captured stale one; see "Client lifetime" below. On the WASM/edge path, createWithStore(workspaceCrn, getJwt, loadToken, saveToken) persists the federated token (e.g. in an HTTP-only cookie) so it survives across requests.

Federation only works if the JWT's issuer is registered with the workspace. Add your OIDC provider — Clerk, Supabase, Auth0, or Okta — in the dashboard under the workspace's OIDC providers; the registered issuer URL must match the JWT's iss claim exactly. An unregistered issuer fails the exchange with No OIDC provider found for issuer: …. A workspace can register multiple OIDC providers, subject to billing conditions / plan level.

The Result trap

As of @cipherstash/auth 0.41, create() returns a Result<Strategy, AuthFailure> ({ data } or { failure }), and so does every getToken(). config.authStrategy expects the strategy itself — the thing with getToken() on it. Passing the un-unwrapped Result is the single most common auth mistake: it type-errors in TS, but plain-JS callers find out only later, as an opaque failure when the client tries to call getToken on an object that doesn't have one. Always: check .failure, pass .data.

AuthFailure is discriminated on type. The ones worth recognising:

failure.type Meaning / fix
NOT_AUTHENTICATED No credentials found — set the CS_* variables, or run npx stash auth login for a dev profile
MISSING_WORKSPACE_CRN CS_CLIENT_ACCESS_KEY is set but CS_WORKSPACE_CRN isn't — the access-key path needs both
WORKSPACE_MISMATCH (expected / actual) The access key belongs to a different workspace than the CRN — you've mixed environments' credentials
INVALID_ACCESS_KEY / INVALID_CRN Malformed values — check for truncation/quoting in the secret store
EXPIRED_TOKEN / INVALID_GRANT The underlying session/JWT expired — for OidcFederationStrategy, check that getJwt returns a fresh token
ACCESS_DENIED CTS refused the exchange — membership, provider config, or (as an HTTP 402) billing

Every failure also carries the live error and, when available, help and url fields — surface them; don't swallow them into a generic message.

Credentials: discovery vs explicit

Native entry (@cipherstash/stack): the default auto strategy discovers credentials — the CS_* environment variables first, then the local dev profile created by npx stash auth login. During local development you typically set nothing at all.

WASM entry (@cipherstash/stack/wasm-inline): no environment discovery, no profile, no device login — Workers and edge runtimes have none of those. All four values are passed explicitly in config, or you construct a strategy yourself and pass config.authStrategy plus clientId/clientKey. On this entry, config.authStrategy and config.accessKey are mutually exclusive — the client throws if both are set. (On the native entry an explicit strategy simply takes precedence over config.accessKey.) See stash-edge for the full edge story.

On both entries, clientKey is always required for encryption regardless of strategy: the service token authenticates requests; the client key is cryptographic key material (level 3 of the hierarchy — stash-zerokms) and is never sent to any service. Auth strategies do not replace it.

The four CS_* variables

Variable What it is Secret?
CS_WORKSPACE_CRN The workspace Cloud Resource Name — carries the region No
CS_CLIENT_ID The ZeroKMS client's identifier No
CS_CLIENT_KEY The client's key material (hex) — combined with key seeds to derive data keys Yes
CS_CLIENT_ACCESS_KEY The access key exchanged at CTS for service tokens Yes

Mint a deployment set with stash env --name <app-env>: it creates a fresh ZeroKMS client and an access key from your logged-in session and prints exactly this dotenv block on stdout (progress goes to stderr, so stash env --name x > prod.env and pipes into secret stores are safe). The access key is minted with the member role — the CLI never mints admin keys — and is shown exactly once. Give each environment its own minted set; see stash-deployment for where each environment's credentials live.

CS_CLIENT_KEY must be hex. Hex is what stash env emits and the only encoding this table has ever documented. The decoder underneath used to fall back to standard padded base64 — the spelling ~/.cipherstash/secretkey.json stores on disk — so a key copied out of that file happened to work despite never being a supported input. It no longer does: the client rejects it at construction with invalid clientKey: expected a hex-encoded key, and the message says nothing further on purpose (the underlying decode error names a character of the key and its offset).

If every operation starts failing at construction after an upgrade, check the encoding before anything else. Re-encode the key as hex — that fix works on both entry points.

On the native entry you may instead drop CS_CLIENT_KEY and let the client read the profile store, which is unaffected: only an explicitly supplied key is hex-only. That escape hatch does not exist on @cipherstash/stack/wasm-inline, where clientId and clientKey are required config and the edge runtimes it targets have no ~/.cipherstash to read. Dropping the variable there replaces one construction failure with another; re-encoding is the only fix.

Client lifetime (user-scoped strategies)

OidcFederationStrategy calls getJwt on every getToken() and keeps one CTS token per distinct IdP JWT (a bounded, least-recently-used cache; a JWT is exchanged only while it has no unexpired token). So one long-lived strategy serves many users, provided getJwt returns the JWT of the user behind the current request — never one captured at startup. getToken() runs getJwt in the caller's own async context, so it may read the request from AsyncLocalStorage the way Clerk's auth() and Next.js headers() do, and a module-level strategy is fine. A stored token (createWithStore, cookies) is likewise served only to the JWT that produced it. Keep getJwt cheap: it runs per operation, and IdP SDKs cache their session, so calling them per request is fine. The cost is one exchange each time the IdP rotates a user's JWT. The cache holds cacheCapacity JWTs — create(crn, getJwt, baseUrl?, cacheCapacity?) on the native entry, { cacheCapacity } in the wasm-inline options — 1024 unless set; past that the least recently used user is exchanged again, and each eviction is logged at debug.

One caveat, for the @cipherstash/stack client on the native entry. The client calls strategy.getToken() from a background task (a Neon channel in protect-ffi), so on that path the request's AsyncLocalStorage is not visible inside getJwt yet. Until the client carries a per-call snapshot (cipherstash/stack#1065), either capture the request in the closure (() => getUserJwt(req)) or build the Encryption() client per request there. This is about where the client calls the strategy, not about the strategy: @cipherstash/auth used directly, and the wasm-inline entry, are unaffected.

On @cipherstash/auth releases before the fix for cipherstash/stack#1045 a strategy held one token and consulted getJwt only when it expired, so a shared client made every caller ride whichever user's token was cached — under lock context, operating (and being audit-logged) as the wrong user. On those versions construct one Encryption() client per request/user.

AccessKeyStrategy and auto authenticate a service, not a user, and are safe to share for the process lifetime either way.

Lock context

Layered on top of OidcFederationStrategy, per operation:

const result = await client
  .encrypt(email, { table: users, column: users.email })
  .withLockContext({ identityClaim: ["sub"] })
  • The mechanism is key-access binding, and it lives in ZeroKMS, not in the ciphertext math: when the data key is generated or retrieved, the named claim (typically sub) is read from the caller's service token and bound to that key. Retrieving the key later — which is what decrypt does — requires presenting a service token carrying exactly the same claim value. Any other caller is refused the key by ZeroKMS; the value never decrypts. So if John encrypts with identityClaim: ["sub"], only a caller whose token carries John's sub gets the key back.
  • This is orthogonal to keysets: keyset access (stash-zerokms) decides which clients can operate on a keyspace at all; lock context adds a per-value, per-caller condition on key retrieval within it. Both gates must pass.
  • AccessKeyStrategy is invalid for lock context on the Stack surface — a service token minted from an access key has no user sub to bind to. (The underlying mechanism binds verified token claims generally, not only user subjects, but .withLockContext({ identityClaim }) is the user-claim path.)
  • The WASM entry does not currently expose .withLockContext() (a known gap, stack#797) — values written there are not identity-bound, and it cannot read values the native entry wrote under a lock context.
  • A lock-context denial is per-value and per-caller (the "gate 2" failure in stash-zerokms): ZeroKMS refused to release that key to that caller. It does not mean the credentials are wrong.

Deprecated, and why

  • config.strategy → renamed config.authStrategy (the old name still works with a runtime warning; authStrategy wins if both are set).
  • LockContext.identify(jwt) and getLockContext() — the old ceremony fetched a per-operation CTS token. protect-ffi 0.25 removed per-operation tokens, so the token identify() fetches is no longer consumed by anything. The strategy handles token acquisition now; .withLockContext() accepts a plain { identityClaim } (a LockContext instance still works). If you're reading example code that calls identify(), it predates this — don't copy it.

Never read ~/.cipherstash

The dev profile (auth.json, secretkey.json, device JWTs) is owned by the CLI and the auth strategies. Agents and application code must never read those files directly — not to "check whether the user is logged in", not to extract a token, not to debug. Run stash auth login / stash env and let the strategies do the reading. A skill about authentication is exactly where you'd be tempted; don't.

Proxy

Authentication through CipherStash Proxy is a different path entirely: the application connects to the Proxy with ordinary PostgreSQL credentials, and the Proxy holds the CipherStash credentials and performs the CTS exchange server-side. A dedicated proxy skill covers it — nothing in this skill's client-side strategy model applies to apps behind the Proxy.

Quick diagnosis

  1. NOT_AUTHENTICATED locally → npx stash auth login (dev profile), or export the four CS_* variables.
  2. Works locally, 401 in CI/production → the deployed environment lacks the CS_* variables, or they're from the wrong workspace — mint with stash env, compare CS_WORKSPACE_CRN.
  3. WORKSPACE_MISMATCH → the access key and the CRN name different workspaces; re-mint rather than mix-and-match.
  4. No OIDC provider found for issuer → add your IdP at dashboard.cipherstash.com → workspace → OIDC providers, and check the issuer URL matches the JWT's iss exactly.
  5. HTTP 402 → billing/usage, not credentials.
  6. Token is fine but operations fail 404/403 → that's keyset access, not authentication — switch to the stash-zerokms runbook.

Version History

  • 38f55d2 Current 2026-10-19 20:01

    修正OIDC策略在Node端的异步上下文使用指导,恢复为单例策略模式;允许通过构建器自定义OIDC策略的JWT缓存容量。

  • 6a92634 2026-09-23 00:00

    对齐诊断行为指南并传播结构化认证拒绝信息

  • f2c5d32 2026-08-20 10:20

Same Skill Collection

.claude/skills/meta-issue-creation/SKILL.md
.claude/skills/meta-pr-creation/SKILL.md
skills/stash-cli/SKILL.md
skills/stash-dynamodb/SKILL.md
skills/stash-encryption/SKILL.md
skills/stash-deployment/SKILL.md
skills/stash-drizzle/SKILL.md
skills/stash-edge/SKILL.md
skills/stash-indexing/SKILL.md
skills/stash-managed-platforms/SKILL.md
skills/stash-postgres/SKILL.md
skills/stash-prisma/SKILL.md
skills/stash-supabase/SKILL.md
skills/stash-supply-chain-security/SKILL.md
skills/stash-zerokms/SKILL.md

Metadata

Files
0
Version
38f55d2
Hash
3b892f34
Indexed
2026-08-20 10:20

홈 - 위키
Copyright © 2011-2026 iteam. Current version is 2.155.2. UTC+08:00, 2026-10-25 17:29
浙ICP备14020137号-1