TL;DR
For a property-management backend that sends an order receipt after a payment settles, I would keep the SMS OTP challenge, throttling counters, recovery-code consumption, and receipt dispatch as separate state transitions, then join them with one correlation ID and four durable audit records: challenge requested, challenge decided, recovery credential consumed, and receipt dispatch authorized. The least complex compliant design is the one that can reconstruct who authorized a receipt without retaining the OTP itself.
The bill is mostly a retention equation, not a message count. Model it before selecting a transport: monthly evidence bytes = attempts x records per attempt x retained bytes per record x retention months, then add the smaller costs of SMS sends, receipt sends, indexes, backups, and audit queries. The term I try to move is retained bytes per record. A compact decision record usually matters more than shaving fields off the outbound message.
There is a cost to that choice. I deliberately stop keeping OTP plaintext, rendered SMS bodies, and recovery-code values; during an investigation, the team can prove the decision path and template version but cannot reproduce a secret or every character a recipient saw. That loss is intentional, though a legal or compliance reviewer may require a different retention boundary for a particular jurisdiction.
What does one settled payment actually cost to evidence?
A payment can settle once while its receipt is requested several times. Those are different facts. The settlement record belongs to the ledger domain; the login challenge belongs to the identity domain; receipt authorization belongs to the communications domain. Treating a successful OTP as permission forever erases that separation and makes later reconciliation much harder.
Keep those ledgers distinct.
I start with units rather than a vendor price sheet. Let S be settled payments, A be OTP attempts per protected session, E be evidence records per attempt, B be average retained bytes per record, and M be retention months. The evidence footprint is proportional to S x A x E x B x M. SMS spend follows successful sends plus retries, while operational cost follows partitions, indexes, backup copies, and the number of joins required to answer an auditor's question. This is not a benchmark; it is the budget equation the service should expose with its own measurements.
The first useful change is to make evidence records narrow and immutable. Store identifiers, timestamps, decision codes, policy versions, and digests of stable inputs. Keep mutable delivery details in an operational table with a shorter lifecycle. A template version and data digest can establish what rendering inputs were approved, while the live phone number and secret remain outside the long-lived record. Your mileage may vary because a regulator, contract, or dispute process can change which fields are legally necessary; the engineering team should not invent that answer.
Consider the complete settlement path before choosing a retention period. A payment processor callback proposes a state change, but the ledger transaction decides whether the payment moves to settled and records the callback's idempotency key. A property manager later signs in, supplies an SMS OTP, and asks to send the tenant's receipt. The identity transaction consumes the challenge exactly once; it does not send anything. The receipt transaction then checks the already-settled payment, current authorization decision, intended recipient reference, and existing logical receipt key before it commits both receipt.authorized and an outbox item. A worker may retry transport delivery, yet every retry points back to that one outbox identity. During reconciliation, the team can therefore ask four separate questions: did settlement commit, did authentication succeed under the stated policy, did the actor authorize this receipt, and which delivery attempts followed? Combining any two of those answers into one mutable status would save a row today and obscure causality later.
This distinction also prevents a costly accounting error: retries are new delivery attempts, not new payment settlements. One payment_id may authorize one logical receipt, and each delivery attempt gets its own identifier. Exactly-once delivery is not a promise I would make across a remote SMS network. Exactly-once authorization inside the database is attainable, provided the receipt outbox row and authorization record commit together.
Short version: count decisions, sends, and retained bytes separately.
How should a NestJS backend throttle SMS OTP and audit recovery codes?
Put the framework controller at the edge and the state machine behind one transaction boundary. A NestJS controller can validate the request and pass a command to the service, but it should not own counters or infer success from a transport response. The same domain contract can be exercised in another language, which is why the example below is Go: the persona requires all code in Go, and the important artifact is the transaction shape rather than a decorator.
The request path should calculate a subject key from the account and purpose, acquire the relevant row or equivalent serializable guard, check both a short-window attempt budget and a longer send budget, create a challenge containing only a digest of the OTP, and append challenge.requested. If the budget is exhausted, return a stable decision such as HTTP 429 with a server-chosen retry time; do not send a new code, and still record the decision. Verification repeats the lock, rejects an expired or already-consumed challenge, compares a candidate digest, consumes the challenge on success, and appends challenge.decided in the same commit.
That ordering matters. If the audit append happens after the commit, a crash can leave an accepted login with no evidence. If the SMS call happens while holding the database transaction, latency at the transport expands lock time and encourages ambiguous retries. I prefer a transactional outbox: commit the challenge, audit record, and send intent together, then let a worker deliver the message under an idempotency key.
package auth
import (
"context"
"errors"
"time"
)
var ErrThrottled = errors.New("otp attempt budget exhausted")
type RequestOTP struct {
AccountID string
Purpose string
CorrelationID string
}
type Challenge struct {
ID string
Digest []byte
ExpiresAt time.Time
}
type Store interface {
WithSerializableTx(ctx context.Context, fn func(Tx) error) error
}
type Tx interface {
AllowAttempt(accountID, purpose string, now time.Time) (bool, time.Time, error)
InsertChallenge(RequestOTP, time.Time) (Challenge, error)
AppendAudit(kind, subjectID, correlationID, policyVersion string, at time.Time) error
EnqueueSMS(challengeID, correlationID string) error
}
func BeginChallenge(ctx context.Context, db Store, cmd RequestOTP, now time.Time) error {
return db.WithSerializableTx(ctx, func(tx Tx) error {
allowed, _, err := tx.AllowAttempt(cmd.AccountID, cmd.Purpose, now)
if err != nil {
return err
}
if !allowed {
return ErrThrottled
}
challenge, err := tx.InsertChallenge(cmd, now)
if err != nil {
return err
}
if err := tx.AppendAudit("challenge.requested", cmd.AccountID, cmd.CorrelationID, "otp-v1", now); err != nil {
return err
}
return tx.EnqueueSMS(challenge.ID, cmd.CorrelationID)
})
}
Production code still needs cryptographic generation, digest comparison, expiry, and key management implementations. Those details should sit behind reviewed interfaces and tests, not be improvised in a tutorial. The contract above makes the non-negotiable invariant visible: no send intent exists without its audit event.
Recovery codes follow the same pattern with a different budget. Generate a finite set, retain only protected representations, show the values once, and consume exactly one matching row under a transaction. A successful consumption appends recovery.consumed, invalidates active OTP challenges for that session, and can require a fresh second-factor enrollment before sensitive work continues. Don't log which plaintext code matched.
Four records are enough when their meanings stay narrow
The four record types are deliberately boring. challenge.requested says a policy allowed creation and queued a send intent. challenge.decided says verification was accepted, rejected, expired, or throttled; it carries a decision code, not a secret. recovery.consumed says a one-time recovery credential changed state. receipt.authorized binds the authenticated subject, settled payment_id, receipt template version, destination reference, and correlation ID to an outbox intent.
| Evidence record | State committed with it | Retain | Exclude |
|---|---|---|---|
challenge.requested |
Challenge and SMS outbox intent | Subject reference, policy version, correlation ID | OTP and rendered body |
challenge.decided |
Attempt count or challenge consumption | Decision code and event time | Candidate OTP |
recovery.consumed |
One-time code consumption | Code-set reference and policy version | Recovery-code plaintext |
receipt.authorized |
Receipt outbox intent | Payment, actor, template, and idempotency references | Live message payload |
One long JSON snapshot would be easier to write and worse to govern. It duplicates mutable profile data, raises retention volume, and makes schema meaning depend on whichever application version emitted the blob. Narrow records permit explicit versioning and targeted access controls. They also make reconciliation mechanical: for every sent receipt, find one authorization; for every authorization, find one settled payment and no more than one logical receipt outbox entry under its idempotency key.
No secret belongs in those joins.
For the receipt itself, template expansion is another controlled input. Mustache escapes variables by default in HTML contexts, while triple braces or ampersands request unescaped output; that distinction deserves an explicit review because a tenant name or memo can be untrusted data. Record the template version and a digest of normalized input data, then render through the approved template path. The Mustache syntax manual is the primary reference for those tags.
An AI agent that can request a receipt should remain outside the authorization boundary. A tool definition can constrain the shape of payment_id, receipt_kind, and reason, as described in Anthropic's tool-use guide, but a valid tool call is still only a request. The backend must independently check payment settlement, actor authorization, OTP or recovery state, and idempotency before it creates receipt.authorized.
Schema validity is not authority.
Test the failure boundaries, not merely the happy response
The valuable tests interleave operations. Run two verification requests against one challenge and assert that only one commits as accepted. Race two recovery submissions and assert that one code consumption wins. Deliver the same outbox item twice and assert that the transport adapter sees the same idempotency key. Request a receipt before settlement and assert that no authorization or send intent is created. Advance a fake clock across the throttle and expiry boundaries instead of waiting in a test.
Then test evidence as a first-class output. Given a correlation ID, the audit reader should explain the policy version, decision sequence, actor reference, payment reference, and receipt authorization without decrypting a phone number or reconstructing an OTP. Given an operational delivery row past its retention deadline, deletion should leave the narrow authorization evidence intact. I am not sure which retention interval is correct without the applicable contract and compliance opinion; the testable requirement is that each class can expire independently and that deletion itself is observable.
Deployment needs the same discipline. Introduce new decision codes with readers that tolerate both schema versions, deploy the writer second, and backfill only derived identifiers that can be proven from existing records. Monitor challenge creation, throttle decisions, verification outcomes, recovery consumption, outbox age, and receipt authorization mismatches as separate signals. Aggregate them; avoid account identifiers in metric labels.
Deploy readers first.
The catch is operational complexity. A transactional outbox, append-only evidence store, and independent retention jobs create more moving parts than a controller that sends an SMS inline. For a low-risk internal tool with no regulated evidence requirement, a smaller session store and ordinary application events may be the honest choice. SMS OTP is also not suitable when the threat model or governing policy requires a stronger phishing-resistant factor; use an approved stronger authenticator and keep the same authorization and audit boundaries. Compliance evidence supports a review, but it does not certify compliance by itself.
What I would stop retaining
I would retain the minimum durable chain that answers authorization and reconciliation questions: stable pseudonymous references, event time, decision, policy and template versions, correlation ID, idempotency key, and input digests where they have a defined comparison procedure. I would place phone numbers, rendered bodies, transport metadata, and diagnostic payloads in separately governed stores with shorter lifetimes. OTP values and recovery-code plaintext would never enter logs or evidence storage.
This choice makes one class of incident investigation less convenient. After operational delivery data expires, investigators may establish that a receipt was authorized and queued but may be unable to reproduce the exact carrier exchange or rendered body. Keep richer data only when a documented obligation and access model justify it. The architecture should make that decision reversible by configuration and migration, while making accidental indefinite retention difficult.
My final criterion is plain: a settled property payment may produce a receipt only after a current authentication decision, and every transition needed to prove that statement must commit with the state it describes. Framework choice does not relax the invariant.
References and Further reading
- Mustache template syntax manual: https://mustache.github.io/mustache.5.html
- Anthropic tool-use overview: https://platform.claude.com/docs/en/agents-and-tools/tool-use/overview
Top comments (1)
This is a clean split, and the line I'd underline is your own — the evidence supports a review, it doesn't certify compliance. What makes that bite is that all four records can be complete, correlated, and internally consistent and still faithfully attest to the wrong human. A session-holding attacker who triggers the receipt flow produces a spotless trail: challenge requested, challenge decided, recovery code burned, receipt authorized, every correlation ID lining up — because the trail records that the flow ran, not that the right person ran it.
So the piece I'd want isn't a fifth record, it's what the challenge-decision actually binds to. If the decision commits nothing stronger than "an OTP that reached this number got echoed back," the whole trail is only ever as trustworthy as SMS possession, and you've built genuinely nice provenance on top of the weakest factor in the stack. Worth deciding up front whether these logs exist to reconstruct authorization after the fact or to make an unauthorized authorization impossible to record cleanly in the first place — those two goals pull the schema in different directions.