DEV Community

loganpierce2073
loganpierce2073

Posted on

Healthtech Phone OTP Authentication Audit Trail — Risk Events and Session Actions

Use a phone OTP flow only when every risk decision and session mutation lands in one append-only audit trail; that correlation is the practical control against bots replaying codes or operators being unable to explain a login. In a healthtech app, the useful unit is not “OTP sent” but a chain from request, challenge, verification, risk evaluation, and session action, each carrying the same correlation identifier.

Short answer: record immutable risk events beside session lifecycle actions, bind them with a request-scoped ID, and make retries idempotent. A successful code check should create one session event, while a denied, expired, or rate-limited attempt should remain visible without exposing the phone number or code.

Invariants and failure boundaries

The audit record needs a stable event envelope: event_id, correlation_id, occurred_at, actor or subject reference, event type, outcome, risk signals, and a schema version. Store a keyed hash of the phone identifier rather than the raw value. OTP material is never an audit field; retaining it turns a diagnostic table into a credential cache.

The exactly-once mindset is useful even when the transport is at-least-once. A unique constraint on (correlation_id, event_type, attempt) makes a retry harmless, and a separate idempotency key protects the session mutation. Do not infer lifecycle state from the last log line: write session.created, session.refreshed, session.revoked, and session.expired as explicit facts.

One short rule: deny first, explain second.

That rule matters for abuse controls. A burst of requests from one device, a high-risk ASN, a SIM-change signal, or repeated invalid codes can produce a risk.evaluated event with a reason code, but the response to the client should stay deliberately vague. OWASP recommends generic authentication responses so an attacker cannot enumerate accounts; the audit trail can be specific for the responder without becoming an oracle for the caller.

How should risk events and session lifecycle actions be correlated?

Correlation must survive queue hops and database retries. Generate the ID at the edge, pass it to the SMS provider adapter and risk engine, and copy it into the transaction that creates or changes the session. A timestamp is not a correlation key: two devices can legitimately verify within the same millisecond, and clock skew makes ordering ambiguous.

I keep two clocks in the envelope: an application timestamp for human timelines and a database sequence for deterministic ordering inside a tenant. When an incident spans services, the sequence is local evidence and the correlation ID is the cross-service join. Your mileage may vary if your data warehouse rewrites timestamps during ingestion, so preserve the original value and timezone offset.

The critical path can remain small and boring. This Go example writes an outbox event in the same transaction as the session transition; a worker later delivers the event to the audit store without changing its meaning.

package auth

import (
    "context"
    "crypto/sha256"
    "encoding/hex"
    "time"
)

type AuditEvent struct {
    EventID      string
    Correlation  string
    Type         string
    Outcome      string
    SubjectHash  string
    OccurredAt   time.Time
    Schema       int
}

type Store interface {
    InsertOutbox(context.Context, AuditEvent) error
    CreateSession(context.Context, string, string) error
}

func subjectHash(phone, pepper string) string {
    sum := sha256.Sum256([]byte(pepper + ":" + phone))
    return hex.EncodeToString(sum[:])
}

func CommitVerified(ctx context.Context, db Store, correlation, eventID, phone, pepper, sessionID string) error {
    event := AuditEvent{
        EventID: eventID, Correlation: correlation, Type: "session.created",
        Outcome: "allowed", SubjectHash: subjectHash(phone, pepper),
        OccurredAt: time.Now().UTC(), Schema: 1,
    }
    if err := db.CreateSession(ctx, sessionID, correlation); err != nil {
        return err
    }
    return db.InsertOutbox(ctx, event)
}
Enter fullscreen mode Exit fullscreen mode

The real transaction must enforce uniqueness and roll back both writes together. If the outbox insert succeeds but delivery is delayed, the session is still explainable; if the session insert fails, no “successful login” event is emitted. That boundary is more valuable than a dashboard that merely counts SMS messages.

Exactly once is a property to design, not a checkbox to claim. Consider a user who taps Verify twice while the first request is waiting on the risk service: both HTTP handlers may pass the code check, one may time out after committing, and the client can retry with a new connection. The database must therefore arbitrate the transition with a compare-and-set on the challenge state, record the winning attempt under the original correlation ID, and return the same session identifier to every later retry. The outbox consumer needs the same discipline. It should acknowledge a delivery only after the audit sink accepts the event, yet it must treat a duplicate event ID as success, because a crash between acceptance and acknowledgement is normal. During reconciliation, I compare the count of terminal challenge states with the count of session transitions, then inspect the small remainder by correlation ID; a nightly aggregate that merely says “SMS sent” cannot reveal this race.

Choosing the ledger shape

There are three common designs, and each fails differently under pressure.

Design Strength Failure mode Suitable use
Mutable login row Easy to query Overwrites evidence and hides retries Small internal tools with no forensic duty
Append-only event ledger Preserves order and decisions Requires schema governance and retention jobs Regulated healthtech authentication
Distributed tracing only Excellent request visualization Sampling and short retention lose audit facts Debugging, never the system of record

I reject “trace-only” logging for this job. A sampled span can show latency but cannot prove that a session was revoked before a protected record was opened. Tracing remains useful as a pointer: put trace_id in the event envelope, then keep the audit ledger independently queryable.

The catch is operational cost. Append-only data needs partitioning, retention, access controls, and a redaction policy for free-text reasons. It is not suitable when the team cannot enforce tenant isolation or respond to deletion requests; in that case, keep a smaller security ledger and route detailed diagnostics to a short-lived, access-logged store. Stick with a mutable operational table for a prototype, but set an explicit migration date before real patient accounts arrive.

Testing abuse paths, not just happy paths

Test the join, not only the endpoint. A replayed verify request should return the original decision and produce no second session.created event. An expired challenge should produce risk.evaluated=denied and challenge.expired, while a revoked session must remain revoked after a refresh retry.

Property-based tests are effective here: generate duplicate delivery, reordered events, and clock skew, then assert that the reconstructed session state is identical. I once started with a test that counted rows and called it idempotency; it passed until a worker retried after a network timeout and created two sessions. The fix was a database uniqueness check, not another assertion in the HTTP handler.

Metrics should expose abuse pressure without leaking identifiers: denial rate by coarse reason, challenge-to-verify latency, duplicate idempotency hits, and outbox lag. Alert on a change in those distributions, then inspect the correlated event chain with privileged access. Never put full phone numbers, OTP values, or clinical context into labels.

Decision record

For this healthtech login, the decision is an append-only, versioned audit ledger joined to session transitions through a correlation ID and protected by idempotency constraints. It supports bot resistance, incident reconstruction, and compliance review while leaving SMS delivery and risk scoring replaceable.

The limitation is intentional: an audit trail does not stop a determined attacker by itself, and it cannot make a weak phone number proof equivalent to phishing-resistant authentication. Add device and network signals, enforce progressive throttling, and offer a stronger factor for clinicians or high-impact actions. If policy requires hardware-backed credentials, choose WebAuthn for that step and retain the same event envelope.

References

Top comments (0)