Identity-aware encryption
Authenticate as the end user with OidcFederationStrategy and bind encryption to a JWT claim with withLockContext, so only that identity can decrypt their data.
Lock encryption to a specific user, so a value can only be decrypted while the client is authenticated as the identity that encrypted it.
How it works
Lock contexts require a Business or Enterprise workspace plan.
Two pieces combine.
An auth strategy decides who the client is when it talks to ZeroKMS. OidcFederationStrategy federates a signed-in user's OIDC JWT (from Supabase, Clerk, Auth0, or Okta) into a CipherStash token, so every ZeroKMS request is made as that user rather than as your service.
A lock context names which claim from that user's JWT to bind the value to, typically sub. ZeroKMS resolves the claim's value from the token authenticating the request and bakes it into the data key's tag.
Lock context is layered on top of the strategy: it requires OidcFederationStrategy, but the strategy does not require lock context. Authenticating as the user gives you a per-user audit trail; adding lock context gives you the cryptographic binding.
Lock contexts are useful for:
- Multi-tenant applications where each user's data must be isolated
- Compliance requirements that demand per-user encryption boundaries
- Applications where you need to prove that only authorized users accessed specific records
Basic usage
Register your identity provider with the workspace first, on the OIDC providers page in the Dashboard. The _ in that URL resolves to whichever workspace you have selected.
Construct the client with OidcFederationStrategy. Pass a function returning the current provider JWT: the strategy calls it again whenever it needs to re-federate, so do not capture a token once.
import { Encryption, OidcFederationStrategy } from "@cipherstash/stack"
import { users } from "./schema"
export const client = await Encryption({
schemas: [users],
config: {
authStrategy: OidcFederationStrategy.create(
process.env.CS_WORKSPACE_CRN,
() => getUserJwt(),
),
},
})OidcFederationStrategy is re-exported from @cipherstash/stack, so a separate @cipherstash/auth import is not needed.
OidcFederationStrategy is the only strategy that supports lock contexts. AccessKeyStrategy authenticates as your service rather than as an end user, so there is no user identity for ZeroKMS to resolve the claim against.
Then bind operations to the user's claim. Every example below uses the client above: without its authStrategy, .withLockContext() has no end-user identity to bind to.
// Encrypt, binding the data key to the user's `sub` claim
const encrypted = await client
.encrypt("sensitive data", { column: users.email, table: users })
.withLockContext({ identityClaim: ["sub"] })
// Decrypt with the same claim, as the same user
const decrypted = await client
.decrypt(encrypted.data)
.withLockContext({ identityClaim: ["sub"] })lockContext.identify(jwt) is deprecated. Per-operation CTS tokens were removed in protect-ffi 0.25, so identify() no longer affects encryption: code that still calls it compiles and logs a deprecation warning, but the token it fetches is unused. Authenticate the client with OidcFederationStrategy and pass the claim to .withLockContext() instead.
Constructing a LockContext is not deprecated, it is simply optional here. .withLockContext() accepts either a LockContext or a plain { identityClaim }.
Supported operations
Lock contexts work with all encrypt and decrypt operations:
const claim = { identityClaim: ["sub"] }
// Single operations
const encrypted = await client.encryptModel(user, users).withLockContext(claim)
const decrypted = await client.decryptModel(encryptedUser).withLockContext(claim)
// Bulk operations
const bulkEncrypted = await client
.bulkEncryptModels(userModels, users)
.withLockContext(claim)
const bulkDecrypted = await client
.bulkDecryptModels(encryptedUsers)
.withLockContext(claim)
// Query operations
const term = await client
.encryptQuery("[email protected]", { column: users.email, table: users })
.withLockContext(claim)Custom identity claims
identityClaim selects which claim (or claims) of the user's JWT ZeroKMS binds to.
| Identity claim | Description |
|---|---|
sub | The user's subject identifier |
scopes | The user's scopes, set by your IdP policy |
Combine claims to scope by identity and permissions together:
await client
.encrypt("sensitive data", { column: users.email, table: users })
.withLockContext({ identityClaim: ["sub", "scopes"] })The same claim must be supplied to decrypt. A value encrypted under ["sub"] will not decrypt under ["sub", "scopes"].
Using with Clerk and Next.js
Clerk is an OIDC provider like any other: hand OidcFederationStrategy a function that returns the current Clerk session token.
import { auth } from "@clerk/nextjs/server"
import { Encryption, OidcFederationStrategy } from "@cipherstash/stack"
import { users } from "./schema"
export async function getClient() {
return Encryption({
schemas: [users],
config: {
authStrategy: OidcFederationStrategy.create(
process.env.CS_WORKSPACE_CRN,
async () => {
const { getToken } = await auth()
return await getToken()
},
),
},
})
}Because the callback is re-invoked on every re-federation, it picks up a refreshed Clerk token automatically.
The @cipherstash/nextjs package (protectClerkMiddleware, getCtsToken) belongs to the earlier CTS-token flow, in which a token was fetched per request and handed to new LockContext({ ctsToken }). Encryption operations no longer consume a CTS token, so that middleware is not required for identity-aware encryption.
Error handling
Encryption operations return a Result.
const result = await client
.encrypt("sensitive data", { column: users.email, table: users })
.withLockContext({ identityClaim: ["sub"] })
if (result.failure) {
console.error("Encryption failed:", result.failure.message)
}Common failure scenarios:
| Scenario | Description |
|---|---|
| Invalid or expired provider JWT | Federation is rejected. The callback should return a live token, not one captured earlier |
| Provider not registered | The OIDC provider has not been added to the workspace |
| Network failure | The CipherStash API could not be reached |
| Missing workspace | CS_WORKSPACE_CRN is not configured |
| Claim mismatch on decrypt | The value was encrypted under a different identityClaim, or as a different user |
See Error handling for the full set of error types.
Setting up indexes
Create PostgreSQL indexes for encrypted columns. Index syntax differs between self-hosted PostgreSQL and managed databases like Supabase.
Model operations
Encrypt and decrypt entire records with schema-driven field selection using encryptModel, decryptModel, bulkEncryptModels, and bulkDecryptModels