Every service checks incoming requests for something: whether a login token is being replayed, whether an uploaded drawing contains anything dangerous, whether a change is safe to merge automatically. The check exists and the tests pass. But look at what the check compares against, and you often find that the reference arrives with the request itself. Then the other side simply edits the content and the reference together. The check runs and stops nothing.
Where do checks break?
The reference a check compares against must come from somewhere the other side cannot touch. A value carried in the request, a rule file supplied by the change, and the raw characters of attacker-written text are all in the attacker's hands. Checking the attacker with them is an exam where the student holds the answer key.
The break is usually one of three things:
- The comparison value comes from the request — the check asks "is this value present, and does it match the one inside the token?" Anyone holding the token can read the inner value and copy it into the request.
- The rules come from the thing being checked — the rule file or config that judges a change is read from that same change. If the change edits the rules, it passes.
- There are two interpreters — a filter scans raw text with a regex, but the real danger appears when a different interpreter (the browser, a shell, a database) decodes that text. The gap between how the two read the same characters is the way through.

A familiar example: store coupons
Say a store's checkout accepts coupon=WELCOME10 and discount=10 together, and the server only checks that the coupon code is valid before applying the discount from the request. There is a check. The coupon is real. But the discount was written by the customer. discount=90 also passes.
The fix is not a stricter check. Stop accepting the discount from the request. The server looks up the rate in its own table by coupon code. When the reference moves from the client's hands to the server's, there is less left to check.
The same shape shows up elsewhere:
- Internal tools — an admin page opens when the request carries an
X-Role: adminheader. Anyone can add that header. - Support bots with exceptions — "lift the paywall when the conversation looks urgent" keyed on a classifier's reading of the message. The user writes that message, so the classification becomes the bypass key.
A one-time value is not "present" — it is "issued by us and used once"
A nonce that prevents token replay must be, as the OpenID Connect spec puts it, a value the client (here, your server) generated and bound to the session. A check of "the request has a nonce and it matches the one inside the token" passes for anyone who reads the token and sends its value back. Have the server issue the value, consume it only after signature verification, and consume it in one statement rather than a read followed by a write.
-- store a hash when issuing; consume it in a single statement when used
DELETE FROM issued_nonces
WHERE nonce_hash = $1 AND expires_at > now()
RETURNING 1; -- zero rows means reject: never issued, already used, or expired
Because it is one statement, two concurrent requests with the same value cannot both get the row.
Why auto-merge rules must be read from the base branch
A workflow like "auto-merge changes that only add one report file" is open the moment its rules are read from the change: the change can rewrite them. GitHub Actions' pull_request_target runs the workflow from the base branch — but checking out and running the change's code throws that advantage away. Keep to:
- Use only the base branch's rules and scripts. Never execute the change's code.
- Merge only the exact commit you checked (so a push after the check cannot slip in).
- Narrow the allow rule — "one new file, matching name pattern, not a symlink".
- Read the file list as structured data; never splice file names into a command string.
Don't filter dangerous syntax — confine the interpreter
If you render user-written SVG diagrams on a page and block external requests by searching the source for url(, the browser's CSS parser decodes escapes and reads \75 rl( as url( anyway. What the filter sees and what the browser reads differ. Instead of adding another banned word, don't inline the SVG into HTML; open it as an image. SVG loaded as an image runs no scripts and loads no external resources. Pull the caption out separately as plain text.
What we ran into
- Social login replay protection — the verification library accepted a request whose nonce matched the token's nonce in plain text. The token's contents are readable base64, so we moved to server-issued nonces consumed in one statement.
- Rendering diagrams — review showed the source-regex filter could be bypassed with CSS escapes. We switched to rendering through an image tag.
- Auto-merging agent reports — the default workflow let the change bring its own rules. Six conditions closed it, among them base-branch rules, the same commit, and a narrow allow rule. A writer with push rights can still upload a normal-looking file, so later agents read it only as reference, never as instructions.
- A safety exception to a paywall — keyed on the message classification, the message became the bypass key. We narrowed it to "the turns right after the current one in the conversation that is open now".
Checklist
Ask each of these when you add a check:
- Where does the value this check compares against come from? If from the request, can the server issue or look it up instead?
- Can the rules or scripts that run this check change together with the thing being checked?
- Does the filter read data the same way as the interpreter that actually consumes it? If not, can you confine that interpreter?
- Can a user create the condition that turns on an exception or bypass path?
- Is there a gap between check and use (a different commit, a two-step read then write) where the value can change?