What a gate actually does
The gate itself is one call; the reference has the statuses it reads.
requireEntitlement("pro") asks the resolver on the request for the caller’s entitlements and looks for the key. Payments fills that resolver. Core’s default holds nothing — so a gate with nothing composed denies, which is the only safe direction for a seam whose absence is otherwise invisible.
The read is one indexed lookup in D1. No KV cache, no claim baked into a token, no in-memory map. What it looks up is an entitlement key rather than a product — the distinction the whole design rests on.
That is a deliberate cost, bought for one property: a revocation is immediate. An entitlement cached anywhere is an entitlement that outlives its revocation by however long the cache lives, and your refund took effect in five minutes is a sentence no support team should have to say. The truth has one home, and it is read every time it matters.
Nothing is resolved until something asks
The middleware installs a resolver; it resolves nothing at install time.
Middleware order belongs to the adopter — it follows the order of the capabilities array in pithy.config.ts — so this middleware may well run before auth has put a caller on the request. Reading the caller at gate time rather than install time makes the resolver correct whatever that order is.
It also means a request that never gates on an entitlement never builds a Kysely instance and never touches D1. The whole cost lands on the routes that asked for it.
Within one request, the subject is resolved once. The promise is memoized rather than the value, so two concurrent gates on one request share one read rather than racing to duplicate it.
The subject seam
Under billingSubject: "user" there is nothing to ask — the caller is the holder, and the authenticated user id is the answer.
Under "organization" the answer is a fact about your membership model: who this person works for, which of their two companies they are looking at right now, whether the seat they were invited to is still theirs. Payments has no members table and no business acquiring one.
So you supply a function. It receives the request and returns a subject, or undefined:
resolveSubject: async (c) =>
organizationOf(c) && {
subjectType: "organization",
subjectId: organizationOf(c),
},For Better Auth’s organization() plugin that is one line reading activeOrganizationId off the session.
The subject is a pair, and both halves are load-bearing
A subject is { subjectType, subjectId }, and it travels as one object rather than two arguments.
Nothing in the kit makes the user id space and your organization id space disjoint. They are minted by different things, and neither knows the other exists. A read filtered on the id alone would hand user:acme whatever organization:acme bought.
Both columns lead the unique constraint, so the pair stays a single indexed lookup rather than a filter applied after the fact.
Expiry is checked on read, not on a schedule
The stored active flag is an optimization. expiresAt is the truth, and the read applies the timestamp itself.
This matters because a subscription can lapse with no notification arriving at all — the store simply stops renewing, and nothing tells you. A row saying active = 1 with an expiry in the past does not grant, and it does not need a write to stop granting.
Lapsed rows are returned, not filtered out, with active: false and their date intact. A paywall wants to say your Pro ended on the 4th, and it can only do that if the row survives the read.
Repairing a stale row is the reconciliation Workflow’s job. A read never writes.
What a denial can tell an operator
A denial carries the provider name — payments — and the holder it resolved for, if it resolved one.
That is what lets somebody reading a log tell genuinely unentitled from nothing wired, without telling the client either. And undefined is a real answer, distinct from holds nothing: it means a caller acting for no organization, which is a different problem from a company that has not bought anything.
The holder’s label is the same string the rails stamp into a store and the same one the support command takes with --subject. One spelling across the whole capability means the string in a log line can be pasted straight into the command that investigates it.
Under organization billing the holder also carries the organization id as the audit trail’s tenant dimension — which is what answers which of our customers is hitting the paywall, a question the actor id cannot answer, because one person acts in two organizations.