Back to Blog
Products

Inside AgeProof: How a Checkout Block Survives a Shopper With DevTools

When the Other Shelf essay introduced AgeProof, it asserted an outcome in one sentence — server-side checkout blocking, so an underage buyer who slips past the front door still can't complete the purchase — and never said how. This is the how, in the tradition of our inside-the-machine pieces: the actual decision the block rests on, the crypto it forced, the budget it runs inside, and — because this series tells the truth or it's worthless — the story of the one link in the chain that was open until this month, and what closing it changed. That last part isn't a confession appended to a pitch. It's the best illustration of the design principle the whole product is built on, arriving the hard way.

The Problem: The Cart Lies

Every storefront age gate ultimately produces the same artifact: some marker that says “this visitor passed.” The naive Shopify implementation writes that marker as a cart attribute — age_verified: true — and lets checkout trust it. The problem is stated bluntly in AgeProof's own source, in the comment that anchors the whole design: the age_verified attribute is buyer-writable — a shopper can POST /cart/update.js from a browser console. That's not a vulnerability in any one theme; it's what cart attributes are. Anything the storefront can write, the person holding the browser can write. A checkout block that trusts a cart flag is a checkout block any twelve-year-old with DevTools open can dismiss in one line.

The Design: Verify a Signature, Not a Flag

So AgeProof's block — a Shopify Checkout Validation Function, running server-side where no browser can reach — refuses to read the flag as an assertion. What the gate stores after a pass is a signed token: a versioned string carrying an expiry date, a random nonce, and an HMAC-SHA256 signature over the lot, keyed by a per-shop secret the browser never sees. The Function checks the token's length before parsing it, verifies the signature with a constant-time comparison, and checks the expiry against the shop's local date. Fail any of that — or simply not have a token — while enforcement is on, the market is in scope, and the cart holds gated products, and checkout is blocked at the cart level. In every other case the Function returns no changes and stays out of the way. (One precision note: on the rare runs where Shopify doesn't supply the shop's local date, the expiry check is skipped — the signature still has to hold.)

The Crypto Is Hand-Written, and Tested Like It Knows It

Shopify Functions run in a WASM sandbox with no WebCrypto, no node:crypto, and no ability to call out to a server — so the SHA-256 and HMAC inside the Function are implemented by hand. Hand-rolled crypto earns its trust or it doesn't, and this is the part of the codebase we treat most seriously: the test suite pins the implementation to published FIPS 180-4 and RFC 4231 test vectors, and — the part that matters most — cross-verifies the two independent implementations against each other: tokens signed by the server's Node crypto must verify byte-for-byte in the hand-written WASM-side verifier. The suite's headline cases are exactly the attacks you'd try first: the pre-signing literal anyone could set from a console ("true" is rejected), a forged signature, and a token whose expiry was extended while carrying the original signature. We re-ran the whole suite this morning: 50 of 50 passing. There's also a fixture that feeds the Function a shop whose cart attribute literally says "true" and expects a block — it passes, and that fixture is the precise scope of the security claim: a hand-set cart attribute is rejected.

The Fix That Became the Story

A signature scheme moves trust; it doesn't create it. Once the Function only accepts signed tokens, the entire question becomes: what will the signer sign? And here is where this article gets honest, because until August 9th, the answer was: too much. The mint endpoint — the app-proxy route the storefront gate calls after a visitor answers it — took the pass/fail verdict from the request body, defaulted anything unrecognizable (including an empty body) to a pass, read the minimum age from the request too, and never compared the claimed age to anything. Net effect: one fetch('/apps/ageproof/verify', {method:'POST'}) from a storefront console returned a genuine, signed, valid token. The signature was real; the thing it attested to was whatever the sender felt like asserting. We found it during a research audit of our own claims, and fixed and shipped it the same day.

The shipped endpoint now states its threat model in its own comment — “everything in the body is buyer-controlled” — and acts like it. The verdict is fail-closed: only the literal string "verified" is even a claim of a pass, and an absent or unparseable body is a rejection, not a default. The minimum age is resolved from the merchant's own settings and per-country rules, through the same parser that builds the Function's config, so the two can't disagree — and never from the request, so a shopper can't lower their own bar. A claimed age must clear that bar on the server before a token exists. And a new merchant setting, requireDob, closes the last soft spot: without it, a shopper on a date-of-birth store could post the confirm-mode shape and skip the age check; with it on, a pass without a birth date is refused no matter what the body claims. The trust boundary this design was always reaching for finally holds all the way down: from anything in the cart, to anything the mint endpoint will sign, to — now — anything the merchant's own configuration says should be signed.

The Boundary That Doesn't Move

Say it plainly, because the product's credibility depends on it: a confirm-mode gate is a self-attestation, and it always will be. When a merchant configures the gate as an “I am of age” button, the shopper's word is the entire evidence, and no amount of cryptography changes what a click means. What cannot be bypassed is the token; what the token attests to is whatever the merchant chose to ask. Date-of-birth mode asks a checkable question and the server now checks it. Confirm mode asks for a promise. And none of it is identity verification — an age gate records a declaration, no theme embed can verify who is holding the mouse, and that refusal has been in print on this shelf since the day the app launched.

The Other Honest Boundaries

Three more limits, each shipped and each visible to the merchant rather than buried here. The app grades its own enforcement. The dashboard's checkout-block tile computes four states — Off, Not running, Bypassable, On — and Bypassable is the app telling the merchant that enforcement is switched on but no gate key exists yet, so the Function is honoring the legacy literal attribute. An app that can be in a degraded state and hides it would be selling the tile, not the block. The config has a cliff, and it's the metafield, not the CPU. Shopify won't return a metafield value over 10,000 bytes to a Function — not truncated, not errored, just absent — which would silently stop the block. AgeProof measures its config size, warns the merchant in the dashboard before the cliff, and ships a per-product mode that keeps the config tiny. And the failure posture is the merchant's choice, stated honestly. Shopify's validation machinery has a switch for what happens when the Function itself can't run — the platform documents it as whether runtime exceptions also block checkout, and publishes no default. AgeProof creates its rule permissive on that axis and gives paying merchants an explicit “keep blocking if Shopify can't run the check” checkbox, because fail-open-by-default versus fail-closed-by-choice is a revenue-versus-compliance tradeoff that belongs to the store, not to us.

The Budget It All Runs Inside

A Checkout Validation Function runs on every checkout, inside hard platform ceilings: Shopify allows 11 million execution instructions and 128 kB of input for carts up to 200 line items, scaling proportionally above that. We measure our worst cases against those ceilings with a benchmark script, and re-ran it this morning. The worst shopper-reachable case — a 200-line cart against a 500-product flagged list — costs 7,450,002 instructions, 67.7% of the ceiling, on a config of 8,154 bytes. The per-product mode at the same cart size runs 44.1%. And the measurements confirm where the real wall is: push the flagged list to 700 products and the config crosses 10,000 bytes and simply stops being delivered — the metafield cliff arrives long before the instruction budget would (a hypothetical 2,000-product config would cost 124.5% of the ceiling, but Shopify would never hand it to the Function in the first place). Numbers like these are why the dashboard warns at the config boundary: the failure mode isn't slowness, it's silence.

What It Costs, and What It Stores

From the live page this morning: Free ($0) ships the storefront gate; the server-side checkout block is paid — Core at $29/month or $290/year adds the block, the append-only compliance log, and CSV export, and Compliance at $59/month or $590/year adds per-product gating, per-country minimum-age rules, and automatic log retention, both paid tiers with a 7-day trial. The compliance log deserves one precise sentence, because logs invite overclaiming: each row records six fields — shop, result, method, the minimum age applied, the claimed age, and a two-letter country code — written server-side at mint time, appended and never edited by the app, with the country regex-validated as a defence against CSV formula injection. No date of birth is ever stored, and no name, email, or identifier of any kind: the birth date is evaluated in the browser, and only the derived age crosses the wire. (That privacy posture is also why the app requests only validation scopes plus one optional product-write — the scope story belongs to Tuesday's essay and we won't re-argue it here.) The disclaimer on the page is the one every product on this shelf carries in some form: it is not legal advice, and it does not by itself guarantee compliance.

One last connective thought. The signed-token pattern here is the same one that guards API sessions and, honestly, half the modern web: move the decision to a place the client can't reach, sign the result, verify the signature where it matters. What AgeProof's August fix demonstrates is the part of that pattern that's easy to skip and fatal to skip: the signer is the security boundary. A perfect verifier downstream of a credulous signer is theater with good cryptography. If you build one of these — age gates, license keys, webhook receipts — audit the mint before you admire the verify. We did it in the wrong order, in public, and the product is better for the correction than it would have been for the silence.

The Gate, the Block, and the Receipts

Free ships the storefront gate; Core adds the server-side block and the append-only log. The mechanism above is the whole trick — and the limits above are the whole truth.

Explore AgeProof
BW

Brandon Wigley

Founder of Wigley Studios. Building developer tools since 2018.

Previous: How to Verify a Webhook Chain All Articles