DEV Community

PeregrineShaw9645
PeregrineShaw9645

Posted on

Bot-Resistant Gaming Password Recovery Through Confirmed Changes and Session Revocation

Short answer: accept every password recovery request with the same public response, spend abuse controls before sending a code, commit the new password exactly once, and revoke every older session only after that commit succeeds.

For a game, recovery is an account-security transaction wrapped in an anonymous endpoint. Players expect a phone code to get them back into an existing account. Bots see a cheap way to enumerate players, burn SMS capacity, annoy a target, or keep replaying a captured code. The practical design goal isn't a clever reset screen. It's a narrow state machine whose public behavior stays neutral while its private decisions remain strict and auditable.

Keep the flow short: an anonymous request is normalized and screened, a generic response is returned, and an eligible account may receive a one-time code through a separate worker. A later confirmation verifies the code against a stored digest, changes the password in one transaction, marks the recovery challenge consumed, and advances the account's session epoch. Every game client carrying an older epoch becomes unauthorized on its next authenticated request.

Stage Public result Private invariant
Request The same acknowledgment Delivery is independently limited
Confirm Success or a generic rejection One live challenge changes one account once
Cleanup No account details exposed Credentials with an older epoch lose authority

How should a password recovery pipeline handle neutral requests, confirmed resets, and session cleanup?

Start by separating acknowledgment from delivery. The request handler should say only that a message may be sent if the account is eligible. It shouldn't reveal whether a phone number exists, whether the player is banned, or whether throttling suppressed delivery. Keep the response body and status the same for those cases, and avoid large timing differences by pushing delivery into an asynchronous job.

Neutral doesn't mean permissive.

Before enqueueing anything, apply limits across several dimensions: a normalized destination, an account when one is found, the source network, and a device or installation signal that has earned enough trust to be useful. A single IP limit is weak for carrier networks and easy to rotate around; a destination-only limit lets an attacker harass one player from many addresses. Layering the signals lets the public request stay boring while the private action can be allowed, delayed, challenged, or suppressed.

The recovery challenge needs its own lifecycle. Store a digest of the code rather than the code itself, bind it to one account and one purpose, give it a short expiry, cap verification attempts, and consume it atomically with the password change. Issuing a newer challenge should invalidate the older one. This prevents two messages from becoming two valid paths back into the same account.

A small TypeScript state machine

The example below keeps transport and storage behind interfaces, so it can sit behind an HTTP handler, a queue consumer, or a test harness. The important part is the transaction boundary. All externally supplied strings are normalized before lookup, and no account-existence result escapes through requestRecovery.

import { createHash, randomInt } from "node:crypto";

type Account = {
  id: string;
  phone: string;
  passwordHash: string;
  sessionEpoch: number;
  recoveryAllowed: boolean;
};

type Challenge = {
  id: string;
  accountId: string;
  codeDigest: string;
  expiresAt: Date;
  attemptsLeft: number;
  consumedAt: Date | null;
};

interface Store {
  findAccountByPhone(phone: string): Promise<Account | null>;
  saveChallenge(challenge: Challenge): Promise<void>;
  confirmInTransaction(input: {
    challengeId: string;
    codeDigest: string;
    passwordHash: string;
    now: Date;
  }): Promise<{ accountId: string; newSessionEpoch: number } | null>;
}

interface RecoveryLimiter {
  allow(input: { phoneKey: string; networkKey: string; deviceKey: string }): Promise<boolean>;
}

interface DeliveryQueue {
  enqueue(input: { accountId: string; challengeId: string; code: string }): Promise<void>;
}

const digest = (value: string) =>
  createHash("sha256").update(value, "utf8").digest("hex");

const normalizePhone = (value: string) => value.replace(/[^+\d]/g, "");

export async function requestRecovery(
  input: { phone: string; networkKey: string; deviceKey: string },
  deps: { store: Store; limiter: RecoveryLimiter; queue: DeliveryQueue; now: Date }
): Promise<{ message: string }> {
  const phone = normalizePhone(input.phone);
  const accepted = await deps.limiter.allow({
    phoneKey: digest(phone),
    networkKey: input.networkKey,
    deviceKey: input.deviceKey,
  });
  const account = accepted ? await deps.store.findAccountByPhone(phone) : null;

  if (account?.recoveryAllowed) {
    const code = randomInt(0, 1_000_000).toString().padStart(6, "0");
    const challenge: Challenge = {
      id: crypto.randomUUID(),
      accountId: account.id,
      codeDigest: digest(code),
      expiresAt: new Date(deps.now.getTime() + 10 * 60_000),
      attemptsLeft: 5,
      consumedAt: null,
    };
    await deps.store.saveChallenge(challenge);
    await deps.queue.enqueue({ accountId: account.id, challengeId: challenge.id, code });
  }

  return { message: "If the account is eligible, a recovery code will be sent." };
}

export async function confirmRecovery(
  input: { challengeId: string; code: string; newPassword: string },
  deps: { store: Store; hashPassword(value: string): Promise<string>; now: Date }
): Promise<{ sessionEpoch: number } | null> {
  const passwordHash = await deps.hashPassword(input.newPassword);
  const result = await deps.store.confirmInTransaction({
    challengeId: input.challengeId,
    codeDigest: digest(input.code),
    passwordHash,
    now: deps.now,
  });

  return result ? { sessionEpoch: result.newSessionEpoch } : null;
}
Enter fullscreen mode Exit fullscreen mode

confirmInTransaction is deliberately the hard part hidden behind a small interface. Its compare-and-update must require an unconsumed challenge, a matching digest, remaining attempts, and an expiry later than now. On success, the same commit writes the new password hash, consumes the challenge, and increments sessionEpoch. On a wrong code, it decrements the attempt budget without changing the password. Concurrent confirmations may race, but only one can satisfy the unconsumed condition.

One commit.

The six-digit code and ten-minute expiry in this sample are policy inputs, not universal recommendations. A code with one million possible values needs strict attempt limits and delivery throttles; extending its lifetime increases the window in which a stolen message can be used. I'm not sure one fixed lifetime makes sense for every game's threat profile. Resolve that with delivery-latency percentiles, observed retry behavior, support volume, and an abuse review rather than intuition.

Put bot resistance before message delivery

An attacker doesn't need to complete a reset to cause damage. Repeated requests can create SMS spend, train players to ignore security messages, and turn recovery into a harassment tool. That makes the delivery decision a security boundary even though the browser always sees the same acknowledgment.

Use coarse network limits to absorb obvious floods, then add destination cooldowns and account-level budgets. Device signals can help, but don't treat a mutable client identifier as identity. Escalate suspicious traffic to a challenge only when the added friction is justified. For a game with frequent legitimate device changes, a permanent device allowlist would lock out exactly the players recovery is meant to help.

Consider one concrete burst rather than a vague "high traffic" label. A script submits the same player's phone number from 40 rotating network addresses while also changing its device identifier, then mixes those calls into ordinary recovery traffic. A network-only limiter sees small independent streams. A device-only limiter sees throwaway identifiers. The destination key, however, joins the burst around the resource being harmed: the player's inbox and the delivery budget. Once that key reaches its cooldown, later calls still receive the neutral acknowledgment but create no delivery job. An account-level budget can add a longer window after lookup, while a broad network budget catches the less careful flood that sprays many destinations. The system should record which private rule made the decision, using pseudonymous keys, so an operator can distinguish a targeted attack from a carrier network shared by legitimate players. This example doesn't prove that 40 is the right threshold; it shows why the dimensions must overlap. Tune each window from observed traffic and support outcomes, and keep the public contract unchanged while those private policies move.

Measure decisions, not secrets. Useful counters include accepted requests, suppressed deliveries by reason, delivery attempts, verification failures, consumed challenges, and recovery-driven session revocations. Logs should carry an internal event ID and pseudonymous account key, never the raw code or new password. Keep public 202 responses separate from private outcomes in the metrics; otherwise a healthy neutral endpoint can hide a delivery queue that isn't keeping up.

The catch is that uniform responses reduce information leakage but make support diagnosis harder. Give authorized support staff a separate, audited view of challenge state and delivery disposition. Don't put those details into the anonymous response, and don't let support bypass the same confirmation transaction.

No exceptions.

Confirmation is the only authority boundary

Possession of the code should authorize one operation: setting a new password for the bound account. It shouldn't mint a general session, reveal profile data, change the phone number, or bypass an account restriction. Keep the recovery grant purpose-specific so a leak has the smallest useful scope.

Validate the new password under the same policy as a normal password change and store it with the application's approved password-hashing function. OWASP's authentication guidance recommends generic authentication responses, secure password storage, reauthentication after risk events, and session invalidation or rotation around sensitive account changes. Those concerns meet at the confirmation transaction: there must be no successful password write that leaves the challenge reusable or old sessions trusted.

Retries deserve explicit treatment. Mobile clients will retry after losing a response, and queues can deliver work more than once. A second confirmation of an already consumed challenge should produce the same generic failure class as any invalid challenge, while an idempotency record can let the original client recover a lost success response without applying the password change twice. Keep that record short-lived and bind it to the exact operation; an unrestricted idempotency key becomes another bearer credential.

Session cleanup must be enforced on every authenticated path

Deleting a row from one session table isn't enough if the game also accepts access tokens, refresh tokens, WebSocket reconnect credentials, or device sessions cached at the edge. A per-account session epoch gives these paths one shared rule. Put the current epoch into newly issued credentials, compare it with authoritative account state during authentication or refresh, and reject credentials whose epoch is older.

This has a real cost: immediate checks against authoritative state add latency or cache complexity to hot game traffic. Short-lived access credentials plus epoch checks at refresh may be a better fit when instant revocation isn't required. Stick with server-side opaque sessions when immediate central revocation matters more than avoiding a lookup. Neither choice removes the need to inventory every credential type, including long-lived launcher sessions and active realtime connections.

After a confirmed reset, notify the account through an independent channel when available, but don't include the password or recovery code. Record the recovery event, the revocation epoch, and the actor class for audit. A player who didn't initiate it needs a clear route to report account takeover; silently changing credentials turns a security control into a delayed surprise.

Operate the pipeline as one security feature

Before release, test that known and unknown phone numbers return equivalent public status, body shape, and broadly comparable timing. Exercise duplicate requests, a newer code replacing an older code, expiry at the boundary, the sixth guess after five failed attempts, two confirmations racing, a lost success response, queue redelivery, and an already connected game client attempting its next authenticated action after reset. Verify that no log, trace, analytics event, or dead-letter payload contains the code or password.

Deploy the state machine and metrics before tuning limits. Watch suppression rates alongside successful confirmations and support contacts; a falling SMS count can mean effective bot controls, or it can mean legitimate players are blocked. Your mileage may vary across regions because delivery delay and shared carrier networks change the shape of normal traffic. Roll limit changes gradually, retain an audited override for support, and rehearse key rotation for the systems that hash or encrypt recovery data.

The final acceptance rule is plain: an anonymous caller learns nothing about account existence, a bot can't turn the endpoint into unbounded message delivery, one valid challenge causes one password change, and that commit removes the authority of older sessions across every game transport. Miss any one of those properties and the feature isn't finished.

References

Top comments (0)