Advanced

Hardening client-server protocols against proxy tools and request forgery

A client-server protocol is the language your game uses to talk to your backend. Attackers can study that language with proxy tools, modified clients, logs, and scripts. Your goal is not to make every request invisible. Your goal is to make bad requests fail safely.

Assume requests can be changed

A player controls the device. That means request fields can be changed before they reach your backend. Item IDs, amounts, timestamps, match results, positions, cooldowns, and reward IDs should all be checked on the server.

Send intent, not final truth

A risky API says, "Set my balance to 10,000." A safer API says, "I want to claim reward X." The server then checks whether reward X exists, is active, was already claimed, and should grant currency.

This design keeps important state on the backend. The client requests actions, but the server decides outcomes.

Risky request

"Set my gems to 10,000."

POST /player/set-balance { "gems": 10000 }

The client sends the final value. If an attacker changes or replays this request, the backend may accept a fake balance.

Safer request

"I want to claim daily reward X."

POST /rewards/claim { "rewardId": "daily_2026_06_06" }

The client sends intent. The backend decides whether the action is valid and calculates the reward itself.

Server checks
Is the session valid?
Is the reward active?
Was it already claimed?
What amount should the server grant?

Use authentication and authorization

Authentication asks, "Who is this?" Authorization asks, "Is this player allowed to do this?" A signed-in player may be allowed to update their own loadout, but not another player's inventory or an admin-only event configuration.

Authorization checks should be close to the action. Do not only check that a user is logged in. Check that the user owns the character, item, match, clan role, purchase, or save slot they are trying to change.

Add replay resistance

Important requests should not work forever. Use short request windows, idempotency keys, nonces, and server-side action ledgers. If the same request is repeated, the backend should know whether it was already processed.

A replay attack is simple: an attacker captures a valid request and sends it again later. The body may be unchanged, the token may still look real, and HTTPS may have been used. If the backend only checks that the request is well formed, the action can happen twice.

replay-risk.txt
1Risky flow:
21. Player claims daily reward.
32. Proxy tool captures the valid POST /rewards/claim request.
43. Attacker sends the same request 50 more times.
54. Backend grants the reward again because the request still looks valid.
6
7Safer flow:
81. Server creates a one-time claim nonce for this player and reward.
92. Client sends that nonce with the claim request.
103. Server consumes the nonce and records the claim in a ledger.
114. Replayed requests fail because the nonce and reward claim were already used.

Nonces work well when the server can issue a fresh one-time value before the action. For example, the server can return a nonce when the player opens the reward screen, starts a match, begins a purchase validation, or asks to craft an item. The nonce should be tied to the account, action, target resource, and a short expiration time.

claim-request.json
1{
2 "rewardId": "daily_2026_06_07",
3 "claimNonce": "nce_7S4qZ9p1",
4 "clientRequestId": "req_0f6b8d8e",
5 "sentAtUnixMs": 1780839600000
6}

The server should not only check that claimNonce exists. It should check who it was issued to, which action it belongs to, whether it expired, and whether it was already consumed. A nonce that was issued for daily_2026_06_07 should not be accepted for a battle-pass reward, purchase grant, or inventory craft.

RewardClaimHandler.cs
1public async Task<ClaimResponse> ClaimDailyRewardAsync(
2 string playerId,
3 ClaimRequest request)
4{
5 var nonce = await nonceStore.ConsumeAsync(new NonceConsumeRequest
6 {
7 PlayerId = playerId,
8 Value = request.ClaimNonce,
9 Action = "claim_daily_reward",
10 ResourceId = request.RewardId,
11 MaxAgeSeconds = 60
12 });
13
14 if (nonce == null)
15 {
16 throw new InvalidOperationException("Replay or expired request");
17 }
18
19 var result = await rewardLedger.InsertOnceAsync(new RewardLedgerEntry
20 {
21 PlayerId = playerId,
22 RewardId = request.RewardId,
23 IdempotencyKey = request.ClientRequestId
24 });
25
26 if (result.AlreadyExists)
27 {
28 return result.OriginalResponse;
29 }
30
31 return await GrantServerCalculatedRewardAsync(playerId, request.RewardId);
32}

Idempotency keys solve a related problem: safe retries. Mobile networks drop requests, players close apps, and clients retry after timeouts. You want the same legitimate retry to return the same result, not grant the reward twice.

The key detail is that idempotency keys must be scoped. A key should belong to one account, one route, one action, and often one payload hash. Do not let a client reuse the same key for different rewards, different purchases, or different inventory changes.

idempotency-ledger.txt
1Ledger row:
2- playerId: player_123
3- route: POST /rewards/claim
4- idempotencyKey: req_0f6b8d8e
5- payloadHash: sha256(rewardId + account + action)
6- status: completed
7- response: { "granted": 100, "currency": "gems" }
8- expiresAt: 2026-06-08T00:00:00Z
9
10Behavior:
11- Same player + same key + same payload = return original response
12- Same player + same key + different payload = reject as suspicious
13- Different player + same key = treat as unrelated or reject by policy

For purchases, replay resistance should include the platform receipt or transaction ID. A receipt should be recorded as consumed or fulfilled on the backend. If the same receipt arrives again, the backend should return the original result or reject it, not grant the same item again.

For match results, use a server-created match ID and submit window. The backend can require that the player was part of the match, the match is still waiting for results, the result version has not already been accepted, and the submitted data matches server-side rules. This stops an old valid result from being replayed after the match is closed.

Rate limit high-risk routes

Login, purchase validation, reward claims, matchmaking, inventory changes, and support actions need rate limits. Rate limits reduce brute force, scripts, accidental loops, and noisy abuse.

Good rate limits usually combine account, device, IP, and route. A purchase validation route may allow fewer attempts than a harmless profile refresh route. A reward claim route may allow retries, but only with the same idempotency key.

Keep production routes boring

Remove debug routes, staging hosts, admin tools, and test switches from release builds. Obfuscating route strings can reduce easy discovery, but production backend routes still need real validation.

Do

  • Validate every important field on the server, including account, item, amount, timing, and state.
  • Use authentication, authorization, rate limits, replay protection, and idempotency together.
  • Design APIs around player intent, not client-provided final truth.

Don't

  • Do not trust request bodies just because they came over HTTPS.
  • Do not let the client send final balances, final ranks, or final rewards as truth.
  • Do not expose debug, admin, or staging routes in production clients.
FAQ

Frequently asked questions.

Short answers to common questions developers ask after reading this article.