> ## Documentation Index
> Fetch the complete documentation index at: https://api.fanvue.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Tokens, encryption and logging with the SDK

> Store Fanvue tokens encrypted with compare-and-swap refresh, issue your own app sessions, scrub logs and guard cron and queue endpoints with the SDK.

If your app calls the API when nobody is looking at it, from a worker, a cron job or a queue, it has to keep refresh tokens. The SDK gives you the pieces to do that safely. It provides an encryption layer, a token store that refreshes without races, your own session tokens for your routes, and filters that keep tokens and personal data out of your logs. You need a database you can write an adapter for and a 32-byte encryption key.

Everything here imports from the core entrypoint, `@fanvue/builder-sdk`, unless a sample says otherwise.

## Encrypt secrets at rest

`createFanvueCrypto({ currentKey, previousKeys })` returns AES-256-GCM encryption over a key registry. `currentKey` is `kid:base64key`, for example `v2:<32 bytes base64>`. Bare key material without a prefix takes the key id `v1` (`DEFAULT_KEY_ID`). `previousKeys` is either `null` or a comma-separated list of retired `kid:base64key` entries, which can decrypt but never encrypt.

| Fact | Value |
| - | - |
| Cipher | AES-256-GCM, 12-byte IV per call, 16-byte tag |
| Key length | 32 bytes (`AES_256_KEY_BYTES`) |
| Wire format | `{kid}.{iv}.{tag}.{ciphertext}`, each part base64url |
| Rotation | Set the new key as `currentKey`, move the old one into `previousKeys`. Existing ciphertexts keep decrypting under their own `kid`. |
| Purpose separation | `withPurpose(purpose)` derives an HKDF-SHA256 subkey, so a value sealed for one purpose cannot be opened by another. |

`withPurpose` returns a `SecretCipher` with only `encrypt` and `decrypt`, which is the type the token store and webhook receiver take. `parseKeyRegistry(config)` validates the configuration without encrypting anything.

Every failure is a `CryptoError` with one of the codes `CRYPTO_KEY_INVALID`, `CRYPTO_KEY_UNKNOWN`, `CRYPTO_MALFORMED_CIPHERTEXT`, `CRYPTO_DECRYPT_FAILED` or `CRYPTO_ENCRYPT_FAILED`. Error messages never contain key material or plaintext.

```typescript lib/crypto.ts theme={null}
import { createFanvueCrypto } from "@fanvue/builder-sdk";

export const fanvueCrypto = createFanvueCrypto({
  currentKey: process.env.TOKEN_ENC_KEY ?? "",
  previousKeys: process.env.TOKEN_ENC_PREVIOUS_KEYS ?? null,
});

export const tokenCipher = fanvueCrypto.withPurpose("fanvue-oauth-tokens");
```

## Store tokens

The token store keeps one encrypted `TokenSet` per subject, where the subject is your own stable id for the creator. You implement `TokenStorageAdapter` over your database, and the SDK owns the refresh logic.

| Adapter method | Role |
| - | - |
| `read(subjectId)` | Returns the `StoredTokenRecord` or `null`. |
| `upsert(subjectId, record)` | Creates or replaces the record. |
| `updateIfRefreshTokenMatches(...)` | Compare-and-swap write used only on the refresh path. |
| `listRecentSubjectIds(limit)` | Most recently updated subjects first, for `getAnyAppAccessToken`. |

`createTokenStoreContext({ adapter, cipher, oauth })` binds the adapter, a `SecretCipher` and your `OAuthConfig`, and wires the SDK's own refresh call.

| Function | Behaviour |
| - | - |
| `storeTokenSet(ctx, subjectId, tokens)` | Encrypts and upserts. Returns `err(MISSING_REFRESH_TOKEN)` when the grant carried no refresh token, because a connection that cannot refresh dies when its access token expires. |
| `getAccessToken(ctx, subjectId)` | Returns a usable access token or `null`. Refreshes when the token is within 60 s of expiry (`TOKEN_EXPIRY_MARGIN_MS`), writing the new pair only if the stored refresh token still matches the one it used. A concurrent refresh that won is detected and its token is served. |
| `getAnyAppAccessToken(ctx)` | Tries the 5 most recently updated subjects (`RECENT_SUBJECT_LIMIT`) and returns the first usable token. For app-bound calls such as the experience token exchange. |
| `tokenSetFromTokenResponse(response, now?)` | Converts a raw `TokenResponse` into a `TokenSet` with an absolute `expiresAt`. |
| `mapOAuthErrorToAppCode(error)` | Maps an `OAuthError` or `EmbeddedAuthError` to the app-facing codes in `OAUTH_APP_ERROR_CODES`, such as `invalid_session_token` and `consent_required`. |

Nothing in the store throws. A `null` from `getAccessToken` means one of these, and in every case you should show the creator a reconnect state:

* the subject never connected
* the subject has no refresh token
* the record can't be decrypted after a key rotation that dropped the old key
* the refresh was refused

Persist tokens as they arrive from the session exchange, then read them from a worker. Note the `throw` inside `onTokens`: `storeTokenSet` never throws, so without it a creator would receive a session whose tokens were never stored.

```typescript lib/token-store.ts theme={null}
import { createTokenStoreContext } from "@fanvue/builder-sdk";
import { createConfig } from "@fanvue/builder-sdk/nextjs/embedded-app";
import { tokenCipher } from "@/lib/crypto";
import { tokenAdapter } from "@/lib/token-adapter";

export const config = createConfig();
export const tokenStore = createTokenStoreContext({ adapter: tokenAdapter, cipher: tokenCipher, oauth: config });
```

```typescript app/api/fanvue/session/route.ts theme={null}
import { storeTokenSet, tokenSetFromTokenResponse } from "@fanvue/builder-sdk";
import { createSessionExchangeHandler } from "@fanvue/builder-sdk/nextjs/embedded-app";
import { config, tokenStore } from "@/lib/token-store";

export const { POST } = createSessionExchangeHandler(config, {
  onTokens: async ({ tokens, user }) => {
    const stored = await storeTokenSet(tokenStore, user.uuid, tokenSetFromTokenResponse(tokens));
    if (stored.isErr()) throw new Error(stored.error.code);
  },
});
```

```typescript workers/daily-digest.ts theme={null}
import { createFanvueClient, getAccessToken } from "@fanvue/builder-sdk";
import { tokenStore } from "@/lib/token-store";

export async function sendDigest(creatorUuid: string) {
  const accessToken = await getAccessToken(tokenStore, creatorUuid);
  if (accessToken === null) return markReconnectRequired(creatorUuid);

  const client = createFanvueClient(accessToken, null);
  const subscribers = await client.subscribers.list({ size: 50 });
  if (subscribers.isErr()) return;
  // ...
}
```

## Issue your own app sessions

`createAppSessions(config)` signs and verifies the bearer JWTs your creator surface and fan surface send to your routes. These are the SDK's own session tokens, not Fanvue tokens. They carry identity claims and no access token, which is why they can live longer than a Fanvue access token.

| Setting | Value |
| - | - |
| Algorithm | HS256, pinned |
| `secret` | At least 32 characters, or `createAppSessions` throws |
| `issuer` | Your app name. Becomes `iss` and prefixes both audiences, `<issuer>:creator` and `<issuer>:fan`. |
| Creator session lifetime | 8 hours default (`DEFAULT_CREATOR_TTL_SECONDS`), override with `creatorTtlSeconds` |
| Fan session lifetime | 12 hours default (`DEFAULT_FAN_TTL_SECONDS`), override with `fanTtlSeconds` |

`CreatorSessionClaimsSchema` carries `typ: 'creator'`, `subjectId`, `fanvueUserUuid`, `displayName` and `handle`. `FanSessionClaimsSchema` carries `typ: 'fan'`, `fanvueFanUuid`, `experienceUuid`, `entitled: true` and `preview`. Extend either with `.extend({ ... })` to add app claims. Never make it `.strict()`, because a verified payload also carries `iss`, `aud`, `sub`, `jti`, `iat` and `exp`.

```typescript lib/sessions.ts theme={null}
import { z } from "zod";
import { createAppSessions, CreatorSessionClaimsSchema, FanSessionClaimsSchema } from "@fanvue/builder-sdk";

export const sessions = createAppSessions({
  issuer: "my-app",
  secret: process.env.SESSION_SECRET ?? "",
  creatorTtlSeconds: null,
  fanTtlSeconds: null,
  creatorClaimsSchema: CreatorSessionClaimsSchema,
  fanClaimsSchema: FanSessionClaimsSchema.extend({ courseId: z.string().min(1) }),
});
```

The result exposes `signCreatorSession`, `signFanSession`, `getCreatorSession(authorizationHeader)` and `getFanSession(authorizationHeader)`. A creator token presented to the fan verifier does not verify, because the audiences differ. Every verification failure returns `null`.

The `@fanvue/builder-sdk/nextjs/embedded-app` entrypoint builds on these sessions:

* `createCreatorSessionHandler` and `createFanSessionHandler` are the bootstrap routes that exchange Fanvue's tokens for app sessions.
* `requireCreatorSession`, `requireFanSession` and `requireCreatorAccessToken` guard your routes. Each returns `{ ok: true, session }`, or `{ ok: false, response }` with the response ready to return.
* `requireCreatorAccessToken` also fetches a live Fanvue token through the token store, and answers `401 fanvue_reconnect_required` when none exists.

```typescript app/api/courses/route.ts theme={null}
import { createFanvueClient } from "@fanvue/builder-sdk";
import { requireCreatorAccessToken } from "@fanvue/builder-sdk/nextjs/embedded-app";
import { sessions } from "@/lib/sessions";
import { tokenStore } from "@/lib/token-store";

export async function GET(request: Request) {
  const guarded = await requireCreatorAccessToken(request, { sessions, tokenStore, isSessionCurrent: null });
  if (!guarded.ok) return guarded.response;

  const client = createFanvueClient(guarded.accessToken, null);
  const me = await client.getCurrentUser();
  return Response.json({ creator: guarded.session.subjectId, handle: me.isOk() ? me.value.handle : null });
}
```

`isSessionCurrent` is an optional revocation check against your own database. A check that throws counts as revoked.

## Protect internal endpoints

`requireMachineAuth(request, options)` authenticates a cron tick or queue drain against your own endpoints. It isn't a way to authenticate to Fanvue. It tries two strategies in order: a shared bearer secret compared in constant time, then an app-supplied `SignedRequestVerifier`.

| `status` | Meaning | Respond |
| - | - | - |
| `authorized` | A strategy accepted the request. | Continue. |
| `unauthorized` | A strategy was available and the request failed it. | `401` |
| `not_configured` | No verifier and no usable secret. A `bearerSecret` under 32 characters (`MINIMUM_BEARER_SECRET_LENGTH`) counts as absent. | `503` |

```typescript app/api/cron/refresh/route.ts theme={null}
import { boundedBatchSize, requireMachineAuth } from "@fanvue/builder-sdk";

export async function POST(request: Request) {
  const auth = await requireMachineAuth(request, {
    bearerSecret: process.env.CRON_SECRET ?? null,
    signedRequestVerifier: null,
    rawBody: null,
  });
  if (auth.status === "not_configured") return new Response(null, { status: 503 });
  if (auth.status === "unauthorized") return new Response(null, { status: 401 });

  const limit = boundedBatchSize(new URL(request.url).searchParams.get("limit"), 20, 100);
  await refreshBatch(limit);
  return Response.json({ processed: limit });
}
```

`boundedBatchSize(requested, defaultSize, max)` clamps an operator-supplied `?limit=` to a positive integer no greater than `max`. Pass a `SignedRequestVerifier` with the exact request bytes in `rawBody` when a queue provider signs its deliveries.

## Scrub logs and error reports

`createSafeLogFields(extraAllowedKeys?)` returns a filter that:

* drops every field not on an allowlist
* drops values that are not `string | number | boolean | null`
* replaces any string containing a UUID or an email address with `[redacted-uuid]` or `[redacted-email]`

`BASE_ALLOWED_LOG_KEYS` holds `cause`, `code`, `creatorId`, `errorName`, `httpStatus`, `reason`, `retryCount` and `status`.

```typescript lib/log.ts theme={null}
import { createLogEvent, createSafeLogFields, errorName } from "@fanvue/builder-sdk";

export const logEvent = createLogEvent({
  prefix: "[my-app]",
  safeLogFields: createSafeLogFields(["courseId"]),
});

try {
  await publishCourse();
} catch (error) {
  logEvent("error", "course.publish_failed", { courseId, errorName: errorName(error), accessToken });
  // logged: [my-app] { event: 'course.publish_failed', courseId: 'c_42', errorName: 'TypeError' }
}
```

`errorName(error)` returns only the class name, never `error.message`, which routinely carries upstream bodies and signed URLs. `logEvent` is a ready-made logger over the base allowlist with the prefix `[fanvue]`.

`createSentryScrubber(extraSensitiveKeyPattern?)` returns a `beforeSend` function for an error-reporting client, with no dependency on one. It redacts values under keys matching `BASE_SENSITIVE_KEY_PATTERN`, which covers `authorization`, `cookie`, `token`, `secret`, `password`, `signedurl`, `api_key`, `credential`, `email`, `phone` and `ip_address`. It also strips UUIDs, emails and URL query strings from every string, and stops its walk at depth 32.

`configurationReadiness(env?)` returns two named checks for a readiness route. The detail names missing variables, never their values.

| Check | Passes when |
| - | - |
| `app_url` | `FANVUE_APP_BASE_URL` or `APP_BASE_URL` is an `https` origin |
| `fanvue_oauth` | `FANVUE_APP_UUID`, `FANVUE_CLIENT_ID`, `FANVUE_CLIENT_SECRET` and `FANVUE_OAUTH_REDIRECT_URI` are set |

## Secrets checklist

* `SESSION_SECRET` of at least 32 random characters. `createConfig` accepts shorter values, but `createAppSessions` and `requireMachineAuth` don't.
* Encryption keys of exactly 32 bytes, base64 encoded, with the key id prefix.
* Client secret, encryption keys and `CRON_SECRET` on the server only. The browser receives the session JWT and nothing else.
* Webhook signing secrets encrypted with their own purpose, as in [Receive webhooks with the SDK](/docs/app-store/sdk/webhooks#mount-the-receiver).

## See also

* [Security](/docs/tutorials/security)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.