Auth for an AI Feature: Who Pays for This Call?
13 min read · updated August 4, 2026
For most endpoints, authentication answers “may this person do this”. For an endpoint that calls a model it must also answer “whose balance does this come out of, and do they have enough” — before the request goes anywhere near a provider. Those are different questions and the second one is the one that empties accounts.
The question is not who, it is who pays
A request to a model endpoint has to resolve four things, and they are genuinely distinct. Conflating any two of them produces a specific, well-known bug.
| Question | Description |
|---|---|
| Identity — who is calling? | A session cookie, a bearer token, a signed request. Answers who, and nothing else. |
| Authorisation — may they do this? | Being signed in is not permission to spend. A read-only member of a workspace is authenticated and must not be able to drain its balance — this is the check most often missing. |
| Attribution — whose balance? | The account, not the user. One user may act for several accounts, and a request that does not name one cannot be billed, limited or explained afterwards. |
| Capacity — is there enough? | Checked before the call, held during it, settled after. A balance read at the start of a 30-second stream is stale by the time the tokens are counted. |
The failure: the endpoint nobody guarded
The realistic version of this bug is not a missing login check. It is a second entrance to the same expensive path, added later, where the guard lives on the component that renders the button rather than on the endpoint that spends the money.
// The page. Looks careful.
export default async function ChatPage() {
const session = await auth();
const canSend = can(session, "spend"); // used to hide the composer
return <Chat canSend={canSend} />;
}
// The endpoint the composer posts to. Guards nothing.
export async function POST(request: Request) {
const session = await auth();
if (!session) return Response.json({ error: "sign in" }, { status: 401 });
// <- no capability check
// <- no account resolution
// <- no balance check
const { messages } = await request.json();
return streamModel(messages); // spends the workspace balance
}The role whose entire description is “read dashboards, change nothing” can drain the workspace with one curl, because the only thing enforcing the capability was a prop that decided whether to render a textarea. A capability enforced by the component that draws the button is not enforced. The same applies to a Server Action, which compiles to an endpoint with a stable id that can be called without ever loading your page.
The general form of the rule: every path that can spend money must run the same four checks, and there must be exactly one function that runs them. Two entrances with two copies of the logic become two entrances with one copy the day somebody fixes a bug in only one of them.
The resolution chain
// lib/payer.ts — the only place that answers "who pays".
import { auth } from "@/lib/auth";
import { verifyApiKey } from "@/lib/api-keys";
import { getAccount } from "@/lib/accounts";
export type Payer = {
accountId: string;
actorId: string; // the user or key that acted
actorKind: "session" | "api_key";
balanceUsd: number;
monthlyLimitUsd: number | null;
monthToDateUsd: number;
allowedModels: string[] | null; // null means no restriction
};
export type PayerResult =
| { ok: true; payer: Payer }
| { ok: false; status: 401 | 403 | 402; error: string };
export async function resolvePayer(request: Request): Promise<PayerResult> {
// 1. Identity. A bearer key wins over a session, so a script calling with
// an explicit key is never silently billed to a stale browser session.
const header = request.headers.get("authorization");
let accountId: string;
let actorId: string;
let actorKind: Payer["actorKind"];
let allowedModels: string[] | null = null;
if (header?.startsWith("Bearer ")) {
const key = await verifyApiKey(header.slice(7));
if (!key) return { ok: false, status: 401, error: "invalid API key" };
if (key.revokedAt) return { ok: false, status: 401, error: "key revoked" };
accountId = key.accountId;
actorId = key.id;
actorKind = "api_key";
allowedModels = key.allowedModels; // keys can be scoped
} else {
const session = await auth();
if (!session) return { ok: false, status: 401, error: "sign in" };
// 2. Authorisation. Signed in is not permission to spend.
if (!session.capabilities.includes("spend")) {
return { ok: false, status: 403, error: "this role cannot spend credit" };
}
accountId = session.accountId;
actorId = session.userId;
actorKind = "session";
}
// 3. Attribution. The account is the unit of billing, not the user.
const account = await getAccount(accountId);
if (!account) return { ok: false, status: 403, error: "no such account" };
if (account.suspended) return { ok: false, status: 403, error: "account suspended" };
// 4. Capacity. 402 is the right status: the request is well-formed and
// permitted, and the reason it cannot proceed is money.
if (account.balanceUsd <= 0) {
return { ok: false, status: 402, error: "no credit remaining" };
}
if (
account.monthlyLimitUsd !== null &&
account.monthToDateUsd >= account.monthlyLimitUsd
) {
return { ok: false, status: 402, error: "monthly limit reached" };
}
return {
ok: true,
payer: {
accountId,
actorId,
actorKind,
balanceUsd: account.balanceUsd,
monthlyLimitUsd: account.monthlyLimitUsd,
monthToDateUsd: account.monthToDateUsd,
allowedModels,
},
};
}Two decisions there are worth defending. The bearer key takes precedence over the session, because a request carrying an explicit credential has said whose it is — falling back to a cookie the browser happened to send is how a script ends up billing the wrong account. And a missing balance is 402 rather than 403, which lets the client distinguish “you are not allowed” from “top up and try again” without parsing prose. Note also that a typed failure union would carry that distinction all the way to the UI.
Hold, then settle
Here is the concurrency bug that a simple balance check does not survive, and it is worth stepping through because it looks correct.
Account balance: $1.00. The user opens ten browser tabs and sends a request in each, within the same second. t=0.00 request 1 reads balance $1.00 → allowed t=0.01 request 2 reads balance $1.00 → allowed (1 has not settled) t=0.02 request 3 reads balance $1.00 → allowed ... t=0.09 request 10 reads balance $1.00 → allowed Ten requests run. Each costs $0.30. Total spend $3.00 against a $1.00 balance. Final balance: −$2.00. The check was not wrong. It was not atomic, and it read a value that every other in-flight request was about to invalidate.
The fix is the same one payment systems use: reserve the money before spending it, then reconcile against the real amount. A hold is written atomically, so ten concurrent requests see ten different remaining balances rather than the same one.
- Estimate the maximum cost of the request from the input length and the
max_tokensceiling. Pessimistic on purpose: a hold that is too small is a hold that does not hold. - Place the hold atomically — one statement that both checks and decrements, so nothing can interleave.
- Make the call. If the hold failed, refuse with a 402 and never contact the provider.
- Settle against the real usage returned by the provider, releasing the difference between the estimate and the actual.
- Release the whole hold on failure — in a
finally, so an exception, a timeout or a client disconnect cannot strand it.
-- The hold, as one atomic statement. The WHERE clause is the check. update accounts set held_usd = held_usd + $2 where id = $1 and (balance_usd - held_usd) >= $2 returning balance_usd - held_usd as remaining; -- Zero rows returned means the hold was refused: there was not enough -- unheld balance. No separate read, so nothing can interleave.
// lib/spend.ts
export async function withHold<T>(
accountId: string,
estimateUsd: number,
run: () => Promise<{ result: T; actualUsd: number }>,
): Promise<T> {
const held = await db.query(HOLD_SQL, [accountId, estimateUsd]);
if (held.rowCount === 0) {
throw new InsufficientCredit("estimated " + estimateUsd.toFixed(4) + " unavailable");
}
let actualUsd = 0;
try {
const { result, actualUsd: spent } = await run();
actualUsd = spent;
return result;
} finally {
// Release the hold and charge what was really used. Runs on the success
// path, on a thrown error, and on an aborted stream.
await db.query(
`update accounts
set held_usd = held_usd - $2,
balance_usd = balance_usd - $3
where id = $1`,
[accountId, estimateUsd, actualUsd],
);
}
}The finally is what makes it correct under the cases that actually happen: a cancelled stream, a provider timeout, a thrown error. Without it, every failed request leaves money reserved and the account slowly becomes unusable for reasons nobody can see — which is a worse bug than the overdraft, because it is silent.
One honest limitation. A stream that is cancelled part-way has produced some tokens, and how many is a number you only get if the provider reports usage on an aborted stream. Where it does not, settle at the estimate rather than at zero: over-charging yourself slightly is preferable to a systematic leak, and it is worth measuring how large the difference is — see what you still pay for after cancelling.
When the caller is a script
If you issue API keys, four properties matter and only the first is usually implemented.
- Store a hash, never the key. Show the key once at creation. A database of live credentials is a breach waiting for a backup to leak — see API key security and encrypting keys at rest.
- A prefix that is not secret. Store the first eight characters in the clear so the dashboard can show
sk_live_a1b2c3d4…and a human can tell two keys apart when deciding which to revoke. - Scope each key. Which models, which endpoints, what monthly ceiling. A key for a scheduled batch job should not be able to call your most expensive model interactively.
- Record last-used. Not for analytics — so that revoking unused keys is a decision somebody can make. A key nobody has used in six months is a liability with no benefit.
// Constant-time comparison, so verification does not leak the hash
// through response timing.
import { createHash, timingSafeEqual } from "node:crypto";
export function hashKey(raw: string): Buffer {
return createHash("sha256").update(raw).digest();
}
export function keysMatch(rawIncoming: string, storedHashHex: string): boolean {
const a = hashKey(rawIncoming);
const b = Buffer.from(storedHashHex, "hex");
return a.length === b.length && timingSafeEqual(a, b);
}When the user brings their own key
Letting a customer supply their own provider key moves the billing relationship to them and moves a serious custody obligation to you. If you offer it, three things are not optional.
- Encrypt at rest with a key your database does not hold. Envelope encryption with a KMS, so a database dump on its own decrypts nothing.
- Never return it. Not in an API response, not in an edit form, not to the user who typed it. Show a masked suffix and offer replacement rather than display.
- Keep it out of logs and error messages. The usual leak is not the database — it is a request object serialised into an error report, headers and all. Redact at the logger, not at the call site, because the call site is where somebody will forget.
And a design note that is easy to miss: with a customer’s own key the balance check moves to the provider, so your 402 path becomes “their key was rejected or is out of credit”. That is a different message and a different action for the user, so it needs its own branch rather than being folded into a generic failure.