OAuth integration mistakes break identity continuity when a login identifier, account link, token rotation, or revocation event is allowed to redefine the player. A stolen gaming session makes the fix concrete. Short answer: keep one internal player ID, attach external identities by stable issuer and subject, rotate refresh tokens as a family, and revoke that family when reuse signals theft.
This is the smallest design I would ship for a one-person SaaS. It outsources OAuth protocol handling, but it does not outsource the identity model. That boundary matters more than the login button.
How can OAuth integration mistakes break identity continuity?
OAuth grants access; OpenID Connect adds an identity layer. Treating either protocol as the player database creates four predictable mistakes.
- Using email as the identity key. Email can change, and the OpenID Connect specification says the combination of issuer (
iss) and subject (sub) is the locally unique, never-reassigned identifier a client can rely on. A verified email is useful account data. It is not the primary key. - Replacing an identity instead of linking it. A player who adds another login method should gain a second credential attached to the same internal player ID. Overwriting the first external identity breaks purchases, saves, bans, and audit history that already point to that player.
- Rotating a token without tracking its family. Issuing a new refresh token and deleting the old row handles the happy path, but loses the lineage needed to distinguish an ordinary refresh from reuse of an already-consumed token. OAuth security guidance describes refresh-token rotation as issuing a new token while invalidating the previous one and retaining the relationship between them.
- Revoking one token but leaving the session alive. A stolen refresh token is a session-level event. Revoking only the current access token, or only the browser cookie, leaves another credential in the same chain able to mint access later.
The identity record and the session record solve different problems. Keep them separate.
The constraint that changed the design
For a game, aggressive security can look exactly like broken identity continuity. A player refreshes on a laptop while a retry from a phone arrives milliseconds later; a network response disappears; two tabs wake after sleep. If every replay-shaped event deletes the whole account, the security control damages the thing it was meant to protect. If every replay is accepted, token theft gets a long runway.
So the decision is narrower: preserve the player, revoke the suspect session family. Purchases and game progress stay attached to the immutable internal playerId; external login records answer who may enter that account; session families answer which devices may continue. That separation buys a useful operational rule. An invalid_grant response during refresh ends the local session and prompts a fresh login. A confirmed reuse of a consumed refresh token revokes the whole family. Neither path deletes or silently remaps the player. The user feels friction, but their identity continuity survives. Don't merge accounts merely because two providers report the same email. Require an authenticated linking ceremony: prove control of the existing account, complete the new provider flow, then add the new (issuer, subject) mapping in one transaction. The catch is extra UI and recovery work. For a disposable guest game with no purchases or durable progress, that ceremony may cost more than the identity is worth; keep guest identity isolated and offer an explicit upgrade instead.
The smallest working implementation
The durable model needs three concepts: player, external identity, and session family. Store only a hash of each refresh-token secret. A lookup identifier can select the row; constant-time comparison then verifies the presented secret. Rotation consumes the current record and creates its child in the same transaction.
type ExternalIdentity = {
playerId: string;
issuer: string;
subject: string;
};
type RefreshToken = {
id: string;
familyId: string;
playerId: string;
secretHash: string;
parentId: string | null;
consumedAt: Date | null;
revokedAt: Date | null;
expiresAt: Date;
};
type RotationResult =
| { kind: "rotated"; refreshToken: string }
| { kind: "reauthenticate" };
interface TokenStore {
transaction<T>(work: (tx: TokenStore) => Promise<T>): Promise<T>;
findById(id: string): Promise<RefreshToken | null>;
insert(token: RefreshToken): Promise<void>;
markConsumed(id: string, at: Date): Promise<boolean>;
revokeFamily(familyId: string, at: Date): Promise<void>;
}
interface TokenCrypto {
verify(secret: string, secretHash: string): Promise<boolean>;
issue(): Promise<{ id: string; secret: string; secretHash: string }>;
}
async function rotateRefreshToken(
presented: { id: string; secret: string },
store: TokenStore,
crypto: TokenCrypto,
now: Date,
): Promise<RotationResult> {
return store.transaction(async (tx) => {
const current = await tx.findById(presented.id);
if (
!current ||
current.revokedAt ||
current.expiresAt <= now ||
!(await crypto.verify(presented.secret, current.secretHash))
) {
return { kind: "reauthenticate" };
}
if (current.consumedAt) {
await tx.revokeFamily(current.familyId, now);
return { kind: "reauthenticate" };
}
const consumed = await tx.markConsumed(current.id, now);
if (!consumed) {
await tx.revokeFamily(current.familyId, now);
return { kind: "reauthenticate" };
}
const next = await crypto.issue();
await tx.insert({
id: next.id,
familyId: current.familyId,
playerId: current.playerId,
secretHash: next.secretHash,
parentId: current.id,
consumedAt: null,
revokedAt: null,
expiresAt: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000),
});
return { kind: "rotated", refreshToken: `${next.id}.${next.secret}` };
});
}
The markConsumed operation must be a conditional update, such as changing consumedAt only where it is still null. That makes two simultaneous refreshes race at the database rather than in application memory: one wins, and the other triggers family revocation. Exact transaction semantics vary by datastore, so verify the conditional-write behavior under real concurrency rather than assuming an ORM call is atomic.
One awkward detail remains: clients can retry after the successful response is lost. I'm not sure there is one correct grace-window policy for every game. A short, bounded idempotency mechanism can reduce false alarms, but it also enlarges the interval in which reuse may pass. Resolve that choice with actual mobile retry telemetry, session value, and the speed of your reauthentication flow. Security versus friction is a product decision here — document it.
Test the theft path, not only login
A green authorization-code flow proves very little about continuity. The valuable tests exercise state transitions: link a second external identity without changing playerId; rotate once and confirm the parent cannot mint another child; submit the consumed parent again and confirm the family is revoked; then verify that a fresh login creates a new family for the same player. Also test two concurrent refresh requests. One may succeed before reuse is detected, but subsequent use by that family must stop.
Log identifiers, not token secrets. A useful security event contains the hashed or opaque family ID, player ID, client instance, provider issuer, reason, and timestamp. Alert on reuse events and sudden reauthentication spikes. Never put authorization codes, access tokens, refresh tokens, or raw cookies in logs.
Ship the schema and rotation path behind a rollout control. Start with internal accounts, watch refresh failure rates, then expand. Keep the old verifier only for the planned migration window and remove it on schedule; an indefinite dual path is two security models to operate. Weekly shipping favors a narrow migration with a rollback trigger, not a six-month authentication rewrite.
What I would change at scale
At higher traffic, I would move family revocation and token consumption into a datastore with explicit conditional writes, partition audit events from the request path, and add automated containment for suspicious reuse patterns. I would also define a short session-risk policy: low-value sessions can reauthenticate normally, while a session protecting purchases or account recovery should demand a stronger authentication step. OWASP recommends reauthentication after high-risk events and invalidating sessions after reauthentication.
More machinery isn't free. Multi-region consistency can add latency; grace windows reduce accidental logouts but soften replay detection; frequent reauthentication improves containment but can push players away. A small team should stick with one region and one transactional store while that setup meets its availability needs. Move to distributed session state only when measured traffic or recovery objectives force the change. Revenue per engineering hour still applies.
The final check is blunt: can the system revoke a stolen session without changing the player's durable ID? If yes, OAuth integration supports identity continuity. If no, another provider or another token library won't repair the data model.
Top comments (0)