SMS OTP, Authenticator App, and Email Code — 3 Gaming Audit Paths

# security# otp# gaming
SMS OTP, Authenticator App, and Email Code — 3 Gaming Audit PathsJethroRhodes8268

A gaming compliance notice changes the 2FA choice because login success is only half the job. The...

A gaming compliance notice changes the 2FA choice because login success is only half the job. The system also needs an auditable record showing which notice was selected, when it was issued, and what the delivery channel reported. My choice is an authenticator app for the primary login factor, with SMS OTP or an email code treated as separately governed fallback paths. Short answer: separate proof of authentication from proof of notice delivery. One event should never pretend to prove both.

That answer is deliberately boring. Good. A fast SDK integration means little if an operator later has to reconstruct a player-access decision from three dashboards and a mailbox-open pixel.

Should gaming login use SMS OTP, an authenticator app, or email code?

A code proves only the condition the verifier actually checked. For an authenticator flow, that condition can be possession of the configured secret at verification time. For SMS or email, it can be possession of a code sent through the selected channel. Neither result, by itself, proves that a compliance notice was displayed, acknowledged, or delivered.

Email adds a specific trap. Apple Mail Privacy Protection downloads remote content in the background and prevents senders from learning whether a recipient opened a message. An open event is weak evidence for this job. It may describe automated content retrieval rather than a human reading a notice.

DKIM solves a different problem. RFC 6376 defines a domain-level signing mechanism that lets a verifier associate a message with a responsible identity. That is useful transport evidence. It does not turn an email open into player acknowledgment, and it does not define the application's compliance state.

So I use three ledgers: an authentication attempt, a notice issue, and a notice acknowledgment. They can share a correlation ID. They cannot share meaning.

One event, one claim.

Path Useful evidence Missing evidence Operational consequence
Authenticator app Verifier accepted or rejected the submitted factor No carrier or mailbox delivery record exists Keep notice delivery independent
SMS OTP Application requested a send and later accepted or rejected a code A send request is not acknowledgment Record channel status separately
Email code Application requested a message and later accepted or rejected a code Open tracking cannot establish a human read Store message identity and application acknowledgment

This constraint changes the architecture. If auditability matters, the simplest design is not the one with the fewest screens. It is the one where each event has one defensible claim.

Consider the awkward path, because the happy path hides bad schemas. A player receives an email code while the compliance notice is queued. The code expires. The player switches to SMS, receives another code, and passes verification. The notice then appears in the application, but the player closes the session before acknowledging it. A single status such as completed loses nearly everything an operator needs to know: which factor succeeded, which attempt expired, whether the notice was available before access, and whether the player acted on it. The ledger should retain two factor attempts, their different channels and outcomes, one notice issue tied to an exact digest, and no acknowledgment. This is also why a transport message ID belongs on an attempt rather than on the player record. Retries can produce more than one transport artifact for one logical operation, while the compliance question remains fixed. Model those facts directly and the investigation is a query. Collapse them into mutable booleans and the investigation becomes guesswork across logs with unrelated retention periods.

The smallest implementation I would ship

The interface below avoids provider fields. It also avoids config sprawl: the application supplies a channel, purpose, subject, and immutable notice digest; adapters deal with transport-specific details outside the domain model.

type FactorChannel = "authenticator" | "sms" | "email";
type AttemptOutcome = "issued" | "accepted" | "rejected" | "expired";

type AuditEvent = {
  eventId: string;
  correlationId: string;
  subjectId: string;
  occurredAt: string;
  kind: "factor_attempt" | "notice_issued" | "notice_acknowledged";
  channel?: FactorChannel;
  outcome?: AttemptOutcome;
  noticeDigest?: string;
  transportMessageId?: string;
};

interface AuditLog {
  append(event: AuditEvent): Promise<void>;
}

interface FactorGateway {
  issue(input: {
    correlationId: string;
    subjectId: string;
    channel: FactorChannel;
  }): Promise<{ transportMessageId?: string }>;

  verify(input: {
    correlationId: string;
    subjectId: string;
    channel: FactorChannel;
    code: string;
  }): Promise<"accepted" | "rejected" | "expired">;
}
Enter fullscreen mode Exit fullscreen mode

The core flow writes the notice issue before asking for acknowledgment. It records factor issuance as a different event. No mutable status row gets overwritten until the history disappears.

async function beginAccessReview(
  input: {
    correlationId: string;
    subjectId: string;
    channel: FactorChannel;
    noticeDigest: string;
  },
  factors: FactorGateway,
  audit: AuditLog,
): Promise<void> {
  await audit.append({
    eventId: crypto.randomUUID(),
    correlationId: input.correlationId,
    subjectId: input.subjectId,
    occurredAt: new Date().toISOString(),
    kind: "notice_issued",
    noticeDigest: input.noticeDigest,
  });

  const issued = await factors.issue(input);

  await audit.append({
    eventId: crypto.randomUUID(),
    correlationId: input.correlationId,
    subjectId: input.subjectId,
    occurredAt: new Date().toISOString(),
    kind: "factor_attempt",
    channel: input.channel,
    outcome: "issued",
    transportMessageId: issued.transportMessageId,
  });
}
Enter fullscreen mode Exit fullscreen mode

There is an intentional omission: this function does not mark the notice as read. That requires an explicit application event after the notice is rendered and the player performs the required acknowledgment. Tiny distinction. Large audit consequence.

Keep it missing.

I would benchmark time-to-first-call for each adapter, but I would reject any comparison that stops there. The useful test fixture is a replayable sequence: notice issued, factor requested, verification rejected, another factor requested, verification accepted, notice acknowledged. The assertion is about the ledger, not the UI toast.

Failure handling without state fiction

Retries need idempotency at the application boundary. A repeated request with the same operation identity should not create a second logical notice. Transport attempts may still produce distinct evidence, so keep an attempt identifier alongside the shared correlation ID.

Do not translate every failure into code invalid. An expired challenge, a rejected value, and a channel that never produced an accepted factor are different audit outcomes. Keep the user-facing message restrained, but preserve the operational distinction in access-controlled logs.

The same rule applies to failover. Moving from email to SMS is a new attempt on a different channel. It is not a mutation of history. The final login result can be successful while the record still shows the abandoned path, which is exactly what an investigator needs.

History stays history.

Delivery reliability belongs in channel telemetry, while authentication correctness belongs in verifier telemetry. Combining them makes both dashboards easier to build and harder to trust. Track issued and terminal outcomes by channel, then inspect incomplete sequences by correlation ID. Do not infer email readership from remote-content loading.

What changes at scale

At low volume, an append-only table and a background dispatcher are enough. At higher volume, I would partition transport work from authentication verification, retain the same domain events, and make consumers idempotent. The schema should survive that move without gaining provider-specific columns.

Retention and access control also become first-class design work. The ledger links security actions to a player account, so collect the minimum fields required for the audit purpose and keep message bodies out of routine event records. A digest can identify the exact notice revision without duplicating its contents in every event.

There is a trade-off. A normalized event model takes more thought than a single last_2fa_status column. It pays back when support, security, and compliance ask different questions of the same incident. I accept that complexity because it is domain complexity, not configuration ceremony.

For the three factor paths, keep policy explicit. An authenticator app avoids dependence on message delivery during the normal login path. SMS and email can cover recovery or constrained users, but each adds a delivery system and a separate failure surface. The correct fallback order depends on account policy and the evidence the gaming operator must retain; the audit contract should remain unchanged.

The decision rule

Choose the primary factor by the login threat model and the user's ability to enroll and recover. Choose notice delivery by the compliance workflow. Then test the crossing points: fallback, expiration, retry, acknowledgment, and replay.

Do not call the cheapest send path the simplest system. The real DX metric is how many special cases leak into the application and how long it takes to explain one access decision from stored events. Three compact interfaces and one stable ledger beat a pile of channel flags.

The final architecture is vendor-neutral: factor adapters can change, transport evidence can vary, and the audit event meanings stay fixed. That is the property worth protecting.

Sources