Verifying rewarded-ad callbacks server-side
Rewarded ads are the ones where a user watches a video and gets something in return. Finish the ad, get some energy, or coins, or an extra life. The flow is nice for everyone when it works: the user gets a reward, we get ad revenue.
The part that is easy to get wrong is how you decide the user actually earned the reward.
The naive version
When the ad finishes, the ad network tells you about it with a callback. Your server gets a request that says, in effect, “user X finished the ad, give them their reward.” You look up user X and add the coins.
If you stop there, you have built a coin printer. Anyone who can figure out the shape of that callback can send it themselves. They do not need to watch an ad. They just hit your endpoint with the right user id and farm rewards all day. And these endpoints are not secret. They get discovered.
So you cannot trust the callback just because it arrived. You have to verify that it really came from the ad network and was not forged.
Why client-side checks do not help
The first instinct is to check things on the client. Confirm in the app that the ad really played, then call your own server.
That does not work, and the reason is simple: the client is fully under the attacker’s control. Someone running a modified app, or just replaying requests with a proxy, can make the client say whatever they want. Any check that runs on a device you do not control is a suggestion, not a guarantee. The decision about whether a reward is real has to happen on the server, using something the client cannot fake.
Signed server-to-server callbacks
The pattern that does work is a signed callback delivered server to server.
The ad network sends the callback straight to your server, not through the app. With each callback it includes a signature: a value computed over the callback contents using a private key that only the ad network holds. You hold the matching public key. You recompute over the contents and check that the signature verifies against that public key.
If it verifies, the callback genuinely came from the ad network and nobody changed it on the way. If it does not, you drop it. An attacker cannot produce a valid signature because they do not have the private key, and that is the whole point of public-key signatures.
A real detail here is key rotation. Networks rotate their signing keys, so the callback also carries a key id. You keep a small set of public keys, look up the one named by the key id, and verify against that. When the network rolls to a new key, the key id changes and you have already fetched the new public key, so verification keeps working without a scramble.
In Go the verify step is small. The hard work is keeping the right public keys around and getting the signed bytes exactly right.
func verify(pub *ecdsa.PublicKey, signedBytes, sig []byte) bool {
hash := sha256.Sum256(signedBytes)
return ecdsa.VerifyASN1(pub, hash[:], sig)
}
The thing to be careful about is signedBytes. You have to sign and verify over the exact same bytes in the exact same order the network specified. If you reorder query parameters or re-encode something, the hash changes and a perfectly valid callback fails to verify. I lost time to this: my signature check kept failing not because the signature was bad but because I was building the signed string slightly differently than the network did.
What I would tell myself starting out
Treat every incoming callback as hostile until the signature says otherwise. The reward is money, and anything that grants money on an unverified request will get abused.
And keep the verification on the server with a key the client never sees. The client can be helpful for the experience, but it cannot be the thing that decides whether a reward is real.