A compliance notice changes the login design because delivery is not the final fact you need to prove. Short answer: create one immutable OTP attempt per authorized issuance, let the client poll a read-only status projection, and record delivery, verification, notice presentation, and acknowledgement as separate evidence events. A failed send may authorize a new attempt under policy. It must never cause the old attempt or code to be sent again.
That distinction is the decision rule. A messaging system can report that it accepted or delivered an SMS, but neither observation proves that the intended person completed 2FA or saw the compliance notice. The application owns those later claims. If an audit record collapses them into success, it will be easy to query and hard to defend.
I've been paged by missed jobs and duplicate deliveries. The common failure wasn't an unusual queue algorithm; it was an ambiguous observation triggering another side effect. A worker lost a response, a scheduler ran again, or a caller timed out, and "I didn't observe success" became "nothing happened." For OTP issuance, that inference can create two live codes and an audit trail that cannot explain which one governed access.
Keep the invariant short: one idempotency key creates at most one attempt, and one attempt owns one code.
Data retention begins with the duplicate-delivery timeline
For a developer tool serving a compliance notice, reconstruct the dangerous timeline before selecting a polling interval or messaging connection. An issuance request is committed, its worker crosses the external send boundary, and the response is lost before the local observation is durable. The scheduler sees pending work. If it treats that missing observation as proof that no SMS left, it sends again; now the user may receive two codes while the database still has a poor account of causality. The preventative move is to make the durable attempt, rather than a worker run or HTTP request, the unit of issuance. The same attempt can be observed and reconciled many times, but it cannot own a second code or authorize a second external send. A replacement is a new, linked attempt approved by policy.
Once that invariant is fixed, write down the claims. The useful evidence chain is issuance_authorized, channel_observed, code_verified, notice_presented, and notice_acknowledged. Each event needs a stable attempt or notice identifier, an observed time, a normalized outcome, and the policy version that permitted the transition. Secrets, OTP values, and full message bodies don't belong in that record.
These claims deliberately have different strength. channel_observed: delivered is an external channel observation. code_verified means the application accepted a single-use credential before its deadline. notice_presented means the authenticated application rendered the notice. notice_acknowledged is a user action tied to a particular notice and policy version. None can substitute for the next.
This framing also settles a surprisingly expensive argument about a sent state. If sent means only that a downstream system accepted a request, name the internal observation accepted; don't display it as proof of delivery. Precise names make the runbook less exciting, which is what I want during an incident.
The OTP controls sit beside the evidence model. OWASP's guidance for one-time recovery codes maps well to an authentication challenge: generate codes with a cryptographically secure mechanism, store them securely, make them single-use, expire them, and rate-limit attempts. Responses should avoid revealing whether an account exists, with consistent wording and timing. Those controls protect the challenge. They still don't turn an SMS receipt into notice acknowledgement.
How should a backend poll SMS 2FA delivery status?
Expose three concepts at the application boundary: begin an attempt, read its current projection, and verify a code against that attempt. A browser receives an opaque attempt ID from the first operation and polls only the read operation while the projection remains pending. It stops on a terminal delivery observation or expiry. Verification addresses the same attempt, so an old code cannot drift into a replacement flow.
Polling must be boring.
A status read does not send a message, extend an expiry, create a replacement, or change evidence. Browsers retry reads, open duplicate tabs, disappear, and resume after network changes; none of those behaviors should alter authentication state. Bounded backoff reduces unnecessary traffic, while the server remains authoritative about the deadline. The browser countdown is display logic.
Delivery ingestion is a separate path. A signed server-to-server callback can reduce observation delay when the channel offers one, and a reconciliation worker can check attempts that remain pending. The browser may still poll the application's normalized projection. This separation matters because external events can be duplicated or arrive out of order: a late pending observation cannot move a delivered attempt backward, and a delivery event cannot modify an already verified attempt.
The response should reveal little: an opaque identifier, a coarse state, an expiry time, and perhaps a retry hint derived from policy. Don't echo the destination or raw channel payload. Keep the transport labels behind an adapter so application states such as pending, delivered, failed, expired, verified, and superseded retain the same meaning if the messaging connection changes.
Test failed-send evidence as monotonic updates
A failed OTP send is not an instruction to resend. It is evidence that closes one attempt. Policy may then authorize a replacement with a fresh attempt ID and a fresh code, linked to the closed record. Account, destination, and network controls should be evaluated again before issuance. This makes the operator-visible reason explicit and preserves the limit that one attempt owns one code.
The alternative is seductive: catch a timeout or negative delivery result and invoke the send function again. Don't. The process may have crossed the external side-effect boundary before losing its response, so automatic resend can produce duplicate delivery. An outbox created in the same database transaction as the attempt narrows the internal crash window: a worker retries publication of the durable command, while the application continues to deduplicate issuance by attempt. The exact guarantee at the messaging boundary depends on the channel contract, so the evidence ledger must retain every normalized observation and its transition decision.
Here is the compact policy I use in reviews:
| Current evidence | New observation | Decision | New outbound SMS |
|---|---|---|---|
| pending | accepted or delivered | append observation; advance monotonically | no |
| pending | permanent failure | close attempt as failed | no |
| pending or delivered | deadline reached | close attempt as expired | no |
| pending or delivered | valid code submitted once | consume code; record verification | no |
| terminal | duplicate or stale event | retain state; record rejection | no |
| failed or expired | policy authorizes replacement | create linked attempt and new code | yes, once |
Rejected transitions matter. During a postmortem, a duplicate event that was seen and deliberately ignored is stronger evidence than its absence from a log. Store the source event identifier when available, the observed time, the normalized event, the prior state, the decision, and a reason. Application logs remain useful for diagnosis, but they aren't the only ledger for a compliance claim.
Test the awkward windows before deployment: terminate a worker before the external send, after the send but before local observation is stored, during duplicate callback handling, and while two correct verification requests race. The verification transaction must atomically establish eligibility, consume the code, and create the access decision. The second request gets a safe denial. No mystery branch.
The following Go sketch keeps evidence rules in domain code rather than in an HTTP handler. An Express service, another web framework, or a reconciliation worker can call the same operation. The store implementation is responsible for a unique idempotency constraint and a transaction that deduplicates the event, checks the allowed source state, updates the projection, and appends the evidence event.
package otp
import (
"context"
"errors"
"time"
)
type State string
const (
Pending State = "pending"
Delivered State = "delivered"
Failed State = "failed"
Expired State = "expired"
Verified State = "verified"
)
type Attempt struct {
ID string
State State
CodeDigest []byte
ExpiresAt time.Time
PolicyVersion string
}
type Store interface {
CreateOnce(ctx context.Context, idempotencyHash string, attempt Attempt) (Attempt, error)
ApplyEvent(
ctx context.Context,
attemptID string,
eventID string,
allowed []State,
next State,
observedAt time.Time,
) (bool, error)
}
func Start(
ctx context.Context,
store Store,
idempotencyHash string,
attempt Attempt,
) (Attempt, error) {
if idempotencyHash == "" || attempt.ID == "" || len(attempt.CodeDigest) == 0 {
return Attempt{}, errors.New("invalid attempt")
}
attempt.State = Pending
return store.CreateOnce(ctx, idempotencyHash, attempt)
}
func RecordDelivery(
ctx context.Context,
store Store,
attemptID string,
eventID string,
delivered bool,
observedAt time.Time,
) error {
next := Failed
if delivered {
next = Delivered
}
_, err := store.ApplyEvent(
ctx,
attemptID,
eventID,
[]State{Pending},
next,
observedAt,
)
return err
}
CreateOnce cannot be a read followed by an insert; concurrent requests would both pass the read. Back it with a unique constraint on a scoped hash of the idempotency key. ApplyEvent should leave a terminal state unchanged when a duplicate or stale event arrives, but append enough information to show why the transition was rejected. A replacement operation belongs elsewhere because it requires a fresh authorization decision and code.
Before rollout, exercise the store through the domain operation, then failure-inject each boundary named earlier. On-call signals should follow the same vocabulary: age of pending attempts, normalized failure classes, rejected transition counts, verification throttles, and reconciliation lag. Apply minimum traffic thresholds before paging on ratios. A dashboard should trace an opaque attempt ID through evidence events without displaying the OTP or full destination. The runbook choices are wait, reconcile, close issuance, or invoke the documented replacement policy. "Send again" isn't a recovery step.
Rollout criteria for authentication ownership
The catch is that polling adds read traffic and observes changes only at its next interval. For high-volume or long-lived work, callbacks plus reconciliation usually make a better ingestion path, even if the browser continues to poll a local projection. Streaming can improve interface latency, but it does not replace durable state or reconciliation.
SMS OTP is not suitable when the threat model requires phishing resistance. Use a phishing-resistant authenticator as the primary factor in that case. It is also a poor choice where users cannot reliably receive SMS or where the organization cannot retain the metadata its evidence policy requires. Stick with an existing identity system when it already owns enrollment, recovery, abuse controls, and audit export; rebuilding only the pleasant login path leaves the difficult security boundary unfinished.
There is no universal retention period I can defend without the jurisdiction, notice type, and organizational policy. I'm not sure a messaging team's default log retention should decide it. A documented data-retention review should set the evidence window, access controls, and deletion process.
For the compliance-notice case, the final rule is narrow: preserve what each system actually observed, never promote delivery into acknowledgement, and make replacement issuance an explicit policy decision. That gives operators a deterministic recovery path and gives reviewers an evidence chain whose claims do not outrun the data.
Top comments (0)