Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 4 additions & 7 deletions content/stack/cipherstash/encryption/bulk-operations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -160,21 +160,18 @@ For the table setup and single-record insert pattern, see [Storing encrypted dat

## Identity-aware bulk encryption

Lock an entire batch to a user's identity by chaining `.withLockContext()`:
Lock an entire batch to a user's identity by chaining `.withLockContext()`. The client must be authenticated as that user with `OidcFederationStrategy`.

```typescript filename="bulk-encrypt-identity.ts"
import { LockContext } from "@cipherstash/stack/identity"

const lc = new LockContext()
const lockContext = (await lc.identify(userJwt)).data!
const claim = { identityClaim: ["sub"] }

const encrypted = await client
.bulkEncrypt(plaintexts, { column: users.email, table: users })
.withLockContext(lockContext)
.withLockContext(claim)

const decrypted = await client
.bulkDecrypt(encrypted.data)
.withLockContext(lockContext)
.withLockContext(claim)
```

See [Identity-aware encryption](/stack/cipherstash/encryption/identity) for the full lock context flow.
Expand Down
14 changes: 8 additions & 6 deletions content/stack/cipherstash/encryption/encrypt-decrypt.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -165,26 +165,28 @@ const back = await client.bulkDecryptModels<User>(encrypted.data)

## Identity-aware operations

Any encrypt or decrypt operation can be scoped to a specific user with a lock context.
Any encrypt or decrypt operation can be bound to a claim from the signed-in user's JWT, provided the client is authenticated as that user with `OidcFederationStrategy`.
See [Identity-aware encryption](/stack/cipherstash/encryption/identity) for details.

```typescript filename="identity-encrypt.ts"
const claim = { identityClaim: ["sub"] }

const encrypted = await client
.encryptModel(user, users)
.withLockContext(lockContext)
.withLockContext(claim)

const decrypted = await client
.decryptModel(encryptedUser)
.withLockContext(lockContext)
.withLockContext(claim)

// Also works with bulk operations
const bulkEncrypted = await client
.bulkEncryptModels(userModels, users)
.withLockContext(lockContext)
.withLockContext(claim)

const bulkDecrypted = await client
.bulkDecryptModels(encryptedUsers)
.withLockContext(lockContext)
.withLockContext(claim)
```

## Audit logging
Expand All @@ -202,6 +204,6 @@ Chain `.audit()` with `.withLockContext()` on the same operation:
```typescript filename="audit-model.ts"
const result = await client
.encryptModel(user, users)
.withLockContext(lockContext)
.withLockContext({ identityClaim: ["sub"] })
.audit({ metadata: { action: "user-signup", requestId: "abc-123" } })
```
210 changes: 93 additions & 117 deletions content/stack/cipherstash/encryption/identity.mdx
Original file line number Diff line number Diff line change
@@ -1,195 +1,171 @@
---
title: Identity-aware encryption
description: Use LockContext in @cipherstash/stack to tie encryption to a user JWT so only that identity can decrypt their data, including Clerk and Next.js setup.
description: 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 by requiring a valid JWT for decryption. When a value is encrypted with a lock context, it can only be decrypted by presenting the same user's identity token.
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

<Callout type="warn">
Lock contexts require a Business or Enterprise workspace plan.
</Callout>

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

The flow is:

1. Create a `LockContext` instance.
2. Identify the user with their JWT.
3. Pass the lock context to encrypt and decrypt operations.

## Basic usage

```typescript filename="identity.ts"
import { LockContext } from "@cipherstash/stack/identity"
Register your identity provider with the workspace first, at [dashboard.cipherstash.com](https://dashboard.cipherstash.com/workspaces/_/oidc-providers).

// 1. Create a lock context (defaults to the "sub" claim)
const lc = new LockContext()
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.

// 2. Identify the user with their JWT
const identifyResult = await lc.identify(userJwt)
```typescript filename="client.ts"
import { Encryption, OidcFederationStrategy } from "@cipherstash/stack"
import { users } from "./schema"

if (identifyResult.failure) {
throw new Error(identifyResult.failure.message)
}
export const client = await Encryption({
schemas: [users],
config: {
authStrategy: OidcFederationStrategy.create(
process.env.CS_WORKSPACE_CRN,
() => getUserJwt(),
),
},
})
```

`OidcFederationStrategy` and `AccessKeyStrategy` are re-exported from `@cipherstash/stack`, so a separate `@cipherstash/auth` import is not needed.

const lockContext = identifyResult.data
Then bind operations to the user's claim:

// 3. Encrypt with lock context
```typescript filename="identity.ts"
// Encrypt, binding the data key to the user's `sub` claim
const encrypted = await client
.encrypt("sensitive data", { column: users.email, table: users })
.withLockContext(lockContext)
.withLockContext({ identityClaim: ["sub"] })

// 4. Decrypt with the same lock context
// Decrypt with the same claim, as the same user
const decrypted = await client
.decrypt(encrypted.data)
.withLockContext(lockContext)
.withLockContext({ identityClaim: ["sub"] })
```

<Callout type="warn">
`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 }`.
</Callout>

## Supported operations

Lock contexts work with all encrypt and decrypt operations:

```typescript filename="identity.ts"
// Single operations
const encrypted = await client
.encryptModel(user, users)
.withLockContext(lockContext)
const claim = { identityClaim: ["sub"] }

const decrypted = await client
.decryptModel(encryptedUser)
.withLockContext(lockContext)
// 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(lockContext)
.withLockContext(claim)

const bulkDecrypted = await client
.bulkDecryptModels(encryptedUsers)
.withLockContext(lockContext)
.withLockContext(claim)

// Query operations
const term = await client
.encryptQuery("user@example.com", {
column: users.email,
table: users,
})
.withLockContext(lockContext)
.encryptQuery("user@example.com", { column: users.email, table: users })
.withLockContext(claim)
```

## Custom identity claims

Override the default context by specifying which identity claims to use:

```typescript filename="identity.ts"
const lc = new LockContext({
context: {
identityClaim: ["sub"], // this is the default
},
})
```
`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 |
| `scopes` | The user's scopes, set by your IdP policy |

Combine claims for identity and permissions scoping:
Combine claims to scope by identity and permissions together:

```typescript filename="identity.ts"
const lc = new LockContext({
context: {
identityClaim: ["sub", "scopes"],
},
})
```

## Using with Clerk and Next.js

Install the `@cipherstash/nextjs` package for automatic CTS token setup with [Clerk](https://clerk.com/):

```bash cta cta-type="install" example-id="install-nextjs-identity"
npm install @cipherstash/nextjs
```

### Set up middleware

In your `middleware.ts`, use `protectClerkMiddleware` to automatically generate CTS tokens for every user session:

```typescript filename="middleware.ts"
import { clerkMiddleware } from "@clerk/nextjs/server"
import { protectClerkMiddleware } from "@cipherstash/nextjs/clerk"

export default clerkMiddleware(async (auth, req) => {
return protectClerkMiddleware(auth, req)
})
await client
.encrypt("sensitive data", { column: users.email, table: users })
.withLockContext({ identityClaim: ["sub", "scopes"] })
```

### Retrieve the CTS token

Use `getCtsToken` to get the CTS token for the current user:

```typescript filename="page.tsx"
import { getCtsToken } from "@cipherstash/nextjs"

export default async function Page() {
const ctsToken = await getCtsToken()
The same claim must be supplied to decrypt. A value encrypted under `["sub"]` will not decrypt under `["sub", "scopes"]`.

if (!ctsToken.success) {
// handle error
}
## Using with Clerk and Next.js

// ctsToken is ready to use
Clerk is an OIDC provider like any other: hand `OidcFederationStrategy` a function that returns the current Clerk session token.

```typescript filename="client.ts"
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()
},
),
},
})
}
```

### Create a LockContext with an existing CTS token

Since the CTS token is already available from the middleware, construct the `LockContext` directly. The CTS token has the shape `{ accessToken: string, expiry: number }`. Passing it directly avoids a second round-trip to CTS.

```typescript filename="page.tsx"
import { LockContext } from "@cipherstash/stack/identity"
import { getCtsToken } from "@cipherstash/nextjs"

export default async function Page() {
const ctsToken = await getCtsToken()

if (!ctsToken.success) {
// handle error
}

const lockContext = new LockContext({ ctsToken })
Because the callback is re-invoked on every re-federation, it picks up a refreshed Clerk token automatically.

// Use lockContext with encrypt/decrypt operations
}
```

`getCtsToken` returns `{ success: true, ctsToken: CtsToken }` on success or `{ success: false, error: string }` on failure.
<Callout type="info">
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.
</Callout>

## Error handling

The `identify` method returns a `Result` type:
Encryption operations return a `Result`.

```typescript filename="identity.ts"
const result = await lc.identify(userJwt)
const result = await client
.encrypt("sensitive data", { column: users.email, table: users })
.withLockContext({ identityClaim: ["sub"] })

if (result.failure) {
// result.failure.type is 'CtsTokenError'
console.error("CTS token exchange failed:", result.failure.message)
console.error("Encryption failed:", result.failure.message)
}
```

Common failure scenarios:

| Scenario | Error type | Description |
|---|---|---|
| Invalid JWT | `CtsTokenError` | The JWT token was rejected by CTS |
| Network failure | `CtsTokenError` | Could not reach the CTS endpoint |
| Missing workspace | Runtime error | `CS_WORKSPACE_CRN` is not configured |
| Expired CTS token | `LockContextError` | The CTS token has expired. Call `identify` again |
| 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](/stack/reference/error-handling) for the full set of error types.
13 changes: 5 additions & 8 deletions content/stack/cipherstash/encryption/models.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -216,27 +216,24 @@ See [Error handling](/stack/reference/error-handling) for the full set of error

## Identity-aware model operations

Chain `.withLockContext()` to bind encryption to a user's JWT:
Chain `.withLockContext()` to bind encryption to a claim from the signed-in user's JWT. The client must be authenticated as that user with `OidcFederationStrategy`.

```typescript filename="model-with-identity.ts"
import { LockContext } from "@cipherstash/stack/identity"

const lc = new LockContext()
const lockContext = (await lc.identify(userJwt)).data!
const claim = { identityClaim: ["sub"] }

// Single record
const encrypted = await client
.encryptModel(user, users)
.withLockContext(lockContext)
.withLockContext(claim)

// Bulk records — one ZeroKMS call, all locked to the same identity
const bulkEncrypted = await client
.bulkEncryptModels(records, users)
.withLockContext(lockContext)
.withLockContext(claim)

const bulkDecrypted = await client
.bulkDecryptModels(encryptedRecords)
.withLockContext(lockContext)
.withLockContext(claim)
```

See [Identity-aware encryption](/stack/cipherstash/encryption/identity) for the full flow.
Expand Down
Loading