Your approval form is untrusted input
Harden a consent endpoint by re-deriving everything from the server-side document, letting the approver's payload only narrow what was asked, and claiming the state transition conditionally.
An approval endpoint has a deceptive threat model. The person clicking “Approve” is your most-trusted user — the owner — so it’s natural to treat the payload their browser sends as an instruction: create these permissions, with this auth type, under these limits. But the payload isn’t the owner; it’s whatever reached your API with the owner’s session. A CSRF’d request, a tampered client, a stale tab replaying an old form, or a bug in your own UI can all submit an approval the owner never saw. If the endpoint materializes permissions from the payload, any of those can mint access nobody asked for and nobody reviewed.
In Cookey, the approval screen is the product’s entire security ceremony: an app submits a grant document (“I want these resources, for these reasons, under these limits”), and the owner reviews and approves it. The commit 5e955af (“fix ui, and security problems”) hardened exactly this seam, and the resulting shape of approveGrant (apps/proxy/src/server/grants/service.ts) is a reusable pattern for any consent flow.
Narrow, derive, claim
The rule: the thing being approved is the frozen server-side document, and the approver’s payload may only narrow it — never name anything outside it, never supply facts the server can derive. Three sub-rules fall out:
- Every resource or action the approval payload mentions must already exist in the stored document, or the request errors.
- Properties that are facts about the other party (here: the app’s auth type) are derived server-side and the client’s claim is ignored.
- The state transition itself is claimed conditionally, so two concurrent approvals can’t both materialize side effects.
What this deliberately doesn’t attempt: judging whether the approval is wise. Spend projections and warnings live in the UI; the endpoint’s only job is ensuring what gets created is a subset of what was reviewed.
The three rules in code
Tighten, never widen. The owner’s per-request action selections are validated as subsets of the document the app authored:
// Owners tighten, never widen: every action named in the decisions
// must appear in the request it belongs to. Without this a crafted
// approval payload could mint a permission the app never asked for.
for (const [rawIndex, actions] of Object.entries(decisions.actions ?? {})) {
const index = Number(rawIndex);
const request = document.requests[index];
if (!request) {
throw new GrantServiceError(
`Action selection refers to request ${rawIndex}, which does not exist`,
);
}
const unknown = actions.filter((a) => !request.actions.includes(a));
if (unknown.length > 0) {
throw new GrantServiceError(
`Request ${rawIndex} was not asked for: ${unknown.join(", ")}`,
);
}
}
The same principle governs the document’s own pre-declared “access options” (bundles the app offers): naming one drops the requests outside it, naming a nonexistent one is an error — because a client and document that disagree is a signal, and “silently approve everything” would be the wrong resolution.
Derive, don’t trust. Whether the grant uses PoP or bearer auth is a property of the app (PoP requires a public key to verify against), so the server recomputes it and pointedly does not read the client’s copy:
// Credential type is the APP's property, not an owner decision: PoP
// needs a public key to verify signatures against, and no approval
// preference can produce one. Derived here rather than read from
// `decisions.auth`, so a stale or hand-crafted payload can neither
// downgrade a signing app to a static token nor claim PoP without a key.
const auth = effectiveAuth(document);
And when the decisions are persisted for the grant-detail page to display later, the derived value overwrites whatever was sent — the stored record must describe what was agreed, not preserve a field the server ignored.
Claim the transition conditionally. The PENDING → ACTIVE flip uses a guarded updateMany inside the transaction, so the status check and the write are one atomic statement:
const claimed = await tx.grant.updateMany({
where: { id: grant.id, status: "PENDING" },
data: { status: "ACTIVE", decisions: { ...decisions, auth }, /* … */ },
});
if (claimed.count === 0) {
throw new GrantServiceError("Grant was already processed", 409);
}
await tx.resourcePermission.createMany({ data: permissionData, skipDuplicates: true });
A plain findUnique-then-update sequence has the obvious race: two tabs, two approvals, two sets of permission rows and two minted tokens. With the conditional claim, the loser sees count === 0 and aborts before any side effects exist for it.
One more detail worth stealing, because it’s the kind of bug approval flows breed — normalization of empty-ish inputs:
// Normalized egress IP list — a whitespace/comma-only value must store
// as null, or the pipeline would treat the grant as IP-pinned while the
// matcher fails open on an empty pattern list.
const egressIps =
decisions.egressIps?.split(/[\n,]/).map((p) => p.trim()).filter(Boolean).join(",") || null;
A textarea containing only a newline is not a policy. Storing it as one would create a grant that claims to be IP-restricted while matching nothing — the worst kind of security setting, one that reports enabled and enforces nothing.
Placement guidance: all of this lives in the service function that performs the approval, behind the route handler — not in the route’s schema validation. Schema validation checks shape; these checks compare the payload against another record’s state, which is the service layer’s job, inside the same transaction that writes.
Disagreement always halts
Every violated rule is a hard, attributed 4xx: unknown action → the exact action names that “were not asked for”; unknown option → the list of options the document actually defines; lost race → 409 “already processed”; approval that narrows everything away → an error demanding at least one action survive, because a grant with zero permissions is a contract that means nothing and would only confuse both parties later. Nothing in this endpoint fails open, and nothing “repairs” a disagreement by guessing — disagreement between client and stored document always halts, on the theory that one of them is stale, buggy, or hostile, and the server can’t know which.
Honesty you can enforce
The practical payoff is that your consent screen’s honesty becomes enforceable rather than aspirational: no matter what evolves in the UI — new frameworks, new form state bugs, a mobile client someone writes later — the set of permissions that can exist is bounded by documents that went through review, because the endpoint structurally cannot mint anything else. Audits get simpler in the same way: “what could this grant allow?” is answered by the frozen document alone, and the stored decisions record is trustworthy because the server wrote back what it did, not what it was told. When you build your next approve/consent/confirm endpoint, the checklist is three questions: what does the payload name that the stored record doesn’t? what does it claim that the server could derive? and what happens when two of them arrive at once?