A key in the browser is gone when the user opens your app on a new device. A background job has no browser to send it. For these cases, save the key on your server, one key for each user and provider.
Do these four steps:
Start with the memory store:
import { memoryCredentialStore } from "@tanstack/ai/byok/server";
export const credentials = memoryCredentialStore();The memory store loses the keys when the process stops. For production, write a store for your database or your secrets manager.
Get the user from your auth, not from the request body:
import { credentials } from "./credentials";
import { getUser } from "./auth";
export async function POST(request: Request) {
const user = await getUser(request);
if (!user) return new Response("Unauthorized", { status: 401 });
const body: unknown = await request.json();
if (
typeof body !== "object" ||
body === null ||
!("key" in body) ||
typeof body.key !== "string"
) {
return new Response("Send { key }", { status: 400 });
}
await credentials.set({
scope: { userId: user.id },
id: "openai",
credential: { type: "api_key", value: body.key },
});
return new Response(null, { status: 204 });
}import { useState } from "react";
export function ServerKeyForm() {
const [value, setValue] = useState("");
return (
<form
onSubmit={(event) => {
event.preventDefault();
void fetch("/api/keys", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ key: value.trim() }),
}).then(() => setValue(""));
}}
>
<input
type="password"
autoComplete="off"
value={value}
onChange={(event) => setValue(event.target.value)}
/>
<button type="submit">Save</button>
</form>
);
}storedProviderKeys reads the key that the user saved:
import {
chat,
chatParamsFromRequest,
keyedAdapter,
toServerSentEventsResponse,
} from "@tanstack/ai";
import { byokMissing, storedProviderKeys } from "@tanstack/ai/byok/server";
import { createOpenaiChat } from "@tanstack/ai-openai";
import { openaiByok } from "@tanstack/ai-openai/byok";
import { credentials } from "./credentials";
import { getUser } from "./auth";
const gpt = keyedAdapter(openaiByok, (key) =>
createOpenaiChat("gpt-6.1-sol", key),
);
export async function POST(request: Request) {
const user = await getUser(request);
if (!user) return new Response("Unauthorized", { status: 401 });
const keys = storedProviderKeys(credentials, { userId: user.id });
const apiKey = await keys.get(openaiByok);
if (!apiKey) return byokMissing(openaiByok);
const params = await chatParamsFromRequest(request);
const stream = chat({
adapter: gpt.create(apiKey),
messages: params.messages,
threadId: params.threadId,
runId: params.runId,
});
return toServerSentEventsResponse(stream);
}How storedProviderKeys finds a key:
Your users now save a key once, and every device and job uses it.
A CredentialStore has four methods, and two optional ones for compare-and-set. You decide where the secrets live:
import type { CredentialScope } from "@tanstack/ai/byok";
type Credential =
| { type: "api_key"; value: string }
| { type: "secret_answer"; value: string; expiresAt: number } // epoch ms
| {
type: "oauth";
accessToken: string;
refreshToken?: string;
expiresAt?: number; // epoch ms
scopes?: Array<string>;
client?: {
clientId: string;
clientSecret?: string;
redirectUri?: string;
issuer?: string;
};
};
interface CredentialStore {
get(key: { scope: CredentialScope; id: string }): Promise<Credential | null>;
set(entry: {
scope: CredentialScope;
id: string;
credential: Credential;
}): Promise<void>;
delete(key: { scope: CredentialScope; id: string }): Promise<void>;
list(
scope: CredentialScope,
): Promise<Array<{ id: string; type: Credential["type"]; expiresAt?: number }>>;
// Optional: compare-and-set.
getVersioned?(key: {
scope: CredentialScope;
id: string;
}): Promise<{ credential: Credential; revision: string } | null>;
setIf?(entry: {
scope: CredentialScope;
id: string;
credential: Credential;
expectedRevision: string | null;
}): Promise<{ ok: true; revision: string } | { ok: false; reason: "conflict" }>;
}Type your store with defineCredentialStore from @tanstack/ai/byok. CredentialScope has tenantId and userId. It can also have a threadId, which the store ignores.
Rules for the keys:
Rules for the secrets (value, accessToken, refreshToken, client.clientSecret):
The harness uses the same store: pass it to createHarnessHost({ credentials }).
Two servers can refresh the same OAuth token at the same time. If the service rotates refresh tokens, the second save then keeps a token that no longer works. Add getVersioned and setIf, so the second save does not remove the first:
To refresh a token in your own route, read it with readCredentialEntry. It finds the credential like readCredential (the user first, then the tenant), and also returns the scope where it found it and its revision:
import { readCredentialEntry } from "@tanstack/ai/byok/server";
import type { Credential, CredentialStore } from "@tanstack/ai/byok";
export async function refreshGithub(
store: CredentialStore,
scope: { tenantId: string; userId: string },
refresh: (current: Credential) => Promise<Credential>,
) {
const entry = await readCredentialEntry(store, scope, "github");
if (!entry) return null;
const next = await refresh(entry.credential);
// Save to the scope where the credential was, also when it is the tenant's.
const key = { scope: entry.scope, id: "github", credential: next };
if (!store.setIf || entry.revision === undefined) {
await store.set(key);
return next;
}
const saved = await store.setIf({ ...key, expectedRevision: entry.revision });
if (saved.ok) return next;
// Another server saved first. Use its token, and do not refresh again.
return (await readCredentialEntry(store, scope, "github"))?.credential ?? null;
}In a harness, run.credentials.refresh does this for you. See Refresh a token.