An Agent Granted 'Watch an Ad to Unlock a Wallpaper' Entirely Client-Side — Re-Verifying Reward Grants with AdMob SSV
I asked an Antigravity agent to wire up 'watch a rewarded ad to unlock a wallpaper,' and it returned an implementation that wrote the unlock flag client-side only. Here is why that is not enough, how I re-verified the reward grant with AdMob server-side verification (SSV), and how I stopped double grants too.
Watch one rewarded ad, unlock one paid wallpaper. When I asked an Antigravity agent to wire up that small flow, the code it returned did work. The wallpaper unlocked the moment the ad closed, and it stayed unlocked the next time the app opened. The trouble was what backed that "stayed unlocked": a single boolean written to the device's local preferences.
I run wallpaper and healing apps on Google Play as an indie developer. Instead of charging, I have long used a design where watching a rewarded ad unlocks a few wallpapers. That is exactly why I stopped the moment I saw this code. This is a question about who gets to decide that a reward — the unlock — is legitimate. It is a trust-boundary question.
The agent took the shortest path to "watch an ad to unlock"
What the agent wrote looked roughly like this. In the ad's reward callback, it persisted the unlock flag straight to the device.
// The agent's first proposal (grant client-side only)rewardedAd.show(activity) { rewardItem -> // view complete = persist the unlock immediately, on device prefs.edit() .putBoolean("wallpaper_${wallpaperId}_unlocked", true) .apply() unlockUi(wallpaperId)}
The behavior looks correct. Watch the ad, it unlocks; restart, it persists. Tests pass too. But that one line — putBoolean(... true) — is the only basis for "is this wallpaper unlocked," and in production that is the weak point.
Local preferences live within reach of the user. On a rooted device or a modified build, you can flip that flag to true without ever watching an ad. The ad callback itself, as long as it stays entirely inside the client, is also a candidate for tampering. In other words both the fact ("an ad was watched") and the decision ("it may be unlocked") were sealed inside the client.
Small as it is, this leaks ad revenue directly. A rewarded ad is a contract — a reward in exchange for the value of a view — and leaving a path to take the reward without paying the view undermines it.
Why a client-side grant alone is not enough
This is where AdMob's Server-Side Verification (SSV) earns its place. When you enable SSV on a rewarded ad unit, Google calls a callback URL on your server every time a user earns a reward. That callback carries the fact that Google considers the reward grantable, signed with ECDSA.
The official docs also recommend using the client-side callback for immediate UX while validating reward legitimacy through SSV (Validate server-side verification (SSV) callbacks). They state plainly that the more a reward affects your app economy, the more the verified server-side callback should be treated as the source of truth. My wallpaper unlock was precisely a reward that "affects the economy."
The line to take home is single: measuring "an ad was watched" can be left to the client and the ad SDK. But deciding "this may be unlocked" is made only by a server that can verify the signature. What the agent short-circuited was collapsing those two into one.
Aspect
Client-side grant only
Grant verified by SSV
Basis for unlock
Local flag on device
Google-signed callback
Spoof resistance
Bypassable by flag edit / APK patch
Grants that fail signature check are rejected
Double grant
Undetectable
Idempotent via transaction_id
Where truth lives
The device
The server
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Understand exactly why a client-side unlock flag can be spoofed, and close the gap today with AdMob rewarded SSV
✦Drop a complete Cloudflare Worker SSV verifier into your own app, including the DER-signature pitfall that Web Crypto silently rejects
✦Take home an idempotent grant built on transaction_id to stop replays and double grants, plus a clear line: let the agent wire and measure, keep the trust boundary yourself
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Before writing code, I redesigned the grant flow. The key is to give each unlock request one opaque server-issued ID, and round-trip it through AdMob's custom_data. That way, when the SSV callback arrives later, the server can uniquely match it to the unlock request it belongs to.
The app asks the server "I want to unlock this wallpaper," and the server issues a single-use grantRequestId, recorded as pending
The app shows the rewarded ad with that grantRequestId in custom_data and a stable user identifier in user_id
On view completion, Google sends a signed callback to my SSV endpoint
The server verifies the signature, deduplicates by transaction_id, then finalizes the matching grantRequestId as unlocked
The app reads the server's unlock state and updates the UI
The client is free to show an optimistic unlock right after the view — that keeps the experience smooth. But the durable truth lives only in the server's finalization.
Client side: optimistic UX, but the real grant is separate
On the Kotlin side, all you do is put the server-issued ID and the user ID into ServerSideVerificationOptions. The difference from the earlier version is one thing: do not write the unlock flag to the device here.
// The unlock request is an opaque server-issued ID. Never generate it on device.val options = ServerSideVerificationOptions.Builder() .setCustomData(grantRequestId) // e.g. "g_8f3c...d21", server-issued, single-use .setUserId(stableUserId) // stable across devices (never a raw email, etc.) .build()rewardedAd.setServerSideVerificationOptions(options)rewardedAd.show(activity) { _ -> // Optimistic UX only. Do not persist "unlocked" here. showOptimisticUnlock(wallpaperId) // The real unlock state comes from the server. viewModel.refreshEntitlement(grantRequestId)}
custom_data arrives percent-encoded in the SSV callback, so decode it on the server. Note too that if user_id or custom_data is unset, that parameter simply will not appear in the callback at all.
Verifying the SSV callback (Cloudflare Worker)
I put the verification endpoint on a Cloudflare Worker, using Web Crypto alone. The steps match the official manual verification: (1) fetch and cache the verifier keys, (2) slice out the signed content, (3) extract the signature and key_id, (4) verify with ECDSA.
There is one pitfall that will trip you up if you copy the doc sample (Tink/Java) verbatim. AdMob's signature is DER-encoded, but Web Crypto's verify only accepts raw r‖s (P1363; 64 bytes for P-256). Hand it DER directly and verification always returns false even though the signature is valid. The code below converts it in derToRaw.
const VERIFIER_KEYS_URL = "https://www.gstatic.com/admob/reward/verifier-keys.json";let keyCache = { at: 0, keys: null };// Never cache keys longer than 24h (keys are rotated).async function getKeys() { const now = Date.now(); if (keyCache.keys && now - keyCache.at < 24 * 60 * 60 * 1000) return keyCache.keys; const res = await fetch(VERIFIER_KEYS_URL, { cf: { cacheTtl: 3600 } }); const json = await res.json(); const map = new Map(); for (const k of json.keys) map.set(String(k.keyId), k.base64); // base64 is DER SPKI keyCache = { at: now, keys: map }; return map;}function b64urlToBytes(s) { s = s.replace(/-/g, "+").replace(/_/g, "/"); while (s.length % 4) s += "="; const bin = atob(s); return Uint8Array.from(bin, (c) => c.charCodeAt(0));}function b64ToBytes(s) { const bin = atob(s); return Uint8Array.from(bin, (c) => c.charCodeAt(0));}// DER(ECDSA) -> raw r‖s 64 bytes (P-256). Web Crypto requires this form.function derToRaw(der) { let o = 0; if (der[o++] !== 0x30) throw new Error("bad DER"); if (der[o] & 0x80) o += (der[o] & 0x7f) + 1; else o++; const readInt = () => { if (der[o++] !== 0x02) throw new Error("bad DER int"); let len = der[o++]; let v = der.slice(o, o + len); o += len; while (v.length > 1 && v[0] === 0x00) v = v.slice(1); // strip leading 0x00 return v; }; const r = readInt(), s = readInt(); const out = new Uint8Array(64); out.set(r, 32 - r.length); out.set(s, 64 - s.length); return out;}async function verifySsv(rawQuery) { // Signed content = everything up to "&signature=". Change neither order nor encoding. const sigMarker = "&signature="; const i = rawQuery.indexOf(sigMarker); if (i === -1) return false; const signedContent = rawQuery.slice(0, i); const params = new URLSearchParams(rawQuery); const keyId = params.get("key_id"); const sigB64url = params.get("signature"); if (!keyId || !sigB64url) return false; const keys = await getKeys(); const spki = keys.get(String(keyId)); if (!spki) return false; // key not found — cache may need refreshing const pub = await crypto.subtle.importKey( "spki", b64ToBytes(spki), { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"] ); const rawSig = derToRaw(b64urlToBytes(sigB64url)); return crypto.subtle.verify( { name: "ECDSA", hash: "SHA-256" }, pub, rawSig, new TextEncoder().encode(signedContent) );}
It also matters that signedContent is sliced from the raw query string, not rebuilt with URLSearchParams. Change the parameter order or encoding by even one character and the signature no longer matches.
Stop double grants and replays with transaction_id
A valid signature is not the finish line. The same callback can arrive twice. Google retries up to five times at one-second intervals if it does not get a response, and the signature passes correctly every time — so signature verification alone does not prevent double grants.
So I use transaction_id (a unique hex ID per grant event) as an idempotency key. Write to KV only on first sight, and skip the grant if it already exists.
export default { async fetch(req, env) { const url = new URL(req.url); const rawQuery = url.search.slice(1); // raw query without the leading "?" const ok = await verifySsv(rawQuery); if (!ok) return new Response("invalid signature", { status: 403 }); const p = new URLSearchParams(rawQuery); const txId = p.get("transaction_id"); const grantRequestId = decodeURIComponent(p.get("custom_data") || ""); const ts = Number(p.get("timestamp") || 0); // Freshness check (optional): drop callbacks that are far too old if (ts && Date.now() * 1000 - ts > 10 * 60 * 1_000_000) { return new Response("stale", { status: 200 }); // return 200 to stop retries } // Idempotency: grant only on first sight of transaction_id const seenKey = `ssv:txn:${txId}`; if (await env.GRANTS.get(seenKey)) { return new Response("dup", { status: 200 }); // already processed; 200 stops retries } await env.GRANTS.put(seenKey, "1", { expirationTtl: 60 * 60 * 24 * 30 }); // Finalize the matching unlock request (pending -> unlocked) await env.GRANTS.put(`grant:${grantRequestId}`, "unlocked", { expirationTtl: 60 * 60 * 24 * 365 }); return new Response("ok", { status: 200 }); // Google expects 200 },};
You can return 403 when verification fails, but the crucial part is to return 200 for the "legitimate but no grant needed" cases — already processed, stale, duplicate. Anything other than 200 keeps Google retrying and bloats your logs. The app reads the state of grant:{grantRequestId} and, once it is unlocked, finalizes the unlock in UI and rendering.
Let the agent measure; keep the trust boundary yourself
I watched my own wallpaper app for about two weeks after switching to this design. Of the events where the client optimistically displayed "unlocked," about 3.4% had no matching SSV that passed verification. Previously that 3.4% would have sailed through as free unlocks. Once verification became the source of truth, grants that fail the signature check dropped to zero, and retry-driven double grants disappeared thanks to the transaction_id idempotency.
The agent was good. Loading the ad, wiring the callback, showing the unlock in the UI — it wrote all that tedious plumbing accurately. But the decision "may this reward be granted" naturally drifts to the shortest path, because a client-only implementation passes tests and works in a demo. That is exactly why I have decided that a trust boundary like a grant is something a human holds in review.
For this kind of work, I draw a line: hand the wiring and the measurement to the agent, but read the code myself for just three things — signature verification, idempotency, and grant finalization. Start by enabling SSV on one of your rewarded ad units and getting the Worker above to pass verification only. That single step makes it concrete where the truth of a grant ought to live.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.