Short answer: keep an append-only authentication audit trail beside the session store, and make every risk event point to the session action it caused.
The decision rule is simple: a phone one-time-code migration is acceptable only when an investigator can reconstruct that chain without guessing from application logs. That means preserving event identity, policy context, and session state across the old managed provider and the replacement path, including during rollback.
That sounds obvious until a support ticket arrives saying “the code was accepted, then the account was locked.” A provider dashboard may show delivery, while the app database shows a refresh-token revocation, and neither record shares a correlation ID. The login worked, yet the audit trail cannot explain it.
What should an authentication ledger record before a session changes?
Treat the ledger as evidence, not as a copy of request logs. Each row needs an immutable event ID, correlation ID, actor (user, device, or operator), tenant, event type, outcome, and server timestamp. Store the risk assessment that was available at that moment: score, rule IDs, network hints, and the model or policy version. Never store the one-time code itself.
The session action belongs in the same vocabulary. session_created, session_step_up_required, session_rotated, session_revoked, and session_expired are clearer than free-form messages. A risk event can then reference the action with caused_action_id; a routine login can reference none and remain auditable.
I once assumed a provider's “delivered” state was enough to close the loop. It wasn't. Delivery is a transport fact, not proof that the intended user passed verification. The ledger must distinguish otp_sent, otp_verified, otp_rejected, and otp_expired, even when those events arrive out of order.
How do risk events and session lifecycle actions correlate?
Use one correlation ID from the first phone challenge through token issuance. Use a separate event ID for each fact, and a parent ID when a retry or step-up branch starts. The write path should be transactional where the session state changes; the notification provider callback can be eventually consistent, but it must preserve the original correlation ID.
from dataclasses import dataclass
from datetime import datetime, timezone
from uuid import uuid4
@dataclass(frozen=True)
class LedgerEvent:
event_id: str
correlation_id: str
event_type: str
outcome: str
session_id: str | None
risk_score: int | None
policy_version: str
occurred_at: str
def record(event_type, outcome, correlation_id, session_id, risk_score, policy_version):
event = LedgerEvent(
event_id=str(uuid4()),
correlation_id=correlation_id,
event_type=event_type,
outcome=outcome,
session_id=session_id,
risk_score=risk_score,
policy_version=policy_version,
occurred_at=datetime.now(timezone.utc).isoformat(),
)
append_only_store.insert(event.__dict__)
return event.event_id
def verify_code(challenge, submitted, session_id, correlation_id):
if challenge.is_expired():
record("otp_expired", "denied", correlation_id, session_id, 72, "login-6")
revoke_session(session_id, reason="expired_challenge", correlation_id=correlation_id)
return False
if not challenge.matches(submitted):
record("otp_rejected", "denied", correlation_id, session_id, 68, "login-6")
return False
record("otp_verified", "accepted", correlation_id, session_id, 12, "login-6")
rotate_session(session_id, correlation_id=correlation_id)
return True
The example deliberately records the decision before calling the state transition. In production, put both operations behind an outbox or database transaction so a process crash cannot leave a new session with no corresponding evidence. Include an idempotency key on callbacks; a repeated otp_verified event should be harmless and visibly marked as a duplicate.
Which failure modes make an audit trail misleading? Clock drift is the quiet one. Compare timestamps from the authentication service, the session database, and the SMS callback, then normalize display to UTC. A callback that appears five minutes before the challenge was issued is usually a clock problem, not fraud.
Keep it boring.
The dangerous failures are semantic:
- A retry reuses the same event ID, hiding how many codes were attempted.
- A logout deletes the session row, erasing the state that an investigator needs.
- A risk score is overwritten after a policy update, so an old decision appears to use today's rules.
- PII such as a full phone number is copied into every log stream.
- A provider callback is trusted by phone number alone, allowing one user's event to attach to another session.
Keep the ledger append-only and redact identifiers at ingestion. A keyed hash of a normalized phone number supports correlation without making the raw number searchable. Retention should follow the financial product's legal policy; “keep everything forever” is not an audit strategy.
How should a fintech team migrate from a managed login provider?
Run the old and new verification paths in shadow mode first. The new path can issue no tokens; it only evaluates the challenge, writes ledger events, and compares outcomes. Sample a fixed percentage, then inspect mismatches by correlation ID. This catches differences in expiry windows, retry counters, carrier callbacks, and risk thresholds before customers see them.
During cutover, keep a hard boundary: one authority may mint sessions, while both systems may emit evidence. A feature flag should select the authority per tenant, and a rollback should revoke sessions minted by the new path without deleting their ledger rows. Record who changed that flag and why. Before switching a tenant, replay a day's worth of synthetic challenges through both state machines, compare every terminal state, and require an explicit sign-off for mismatches; otherwise a harmless-looking difference in retry counting can become a lockout storm when traffic shifts.
The catch is operational load. A self-managed challenge service is a poor fit when your team cannot operate key rotation, abuse controls, delivery monitoring, and incident response at all hours. Stick with the managed provider when its export format preserves event identity and your regulatory review accepts the dependency. Move only when you can own the state machine and the evidence contract.
| Decision area | Managed path | Self-managed path | Evidence to require |
|---|---|---|---|
| Session authority | Provider or adapter | Your session service | One issuer per tenant during cutover |
| Risk policy | External rules | Versioned local policy | Rule IDs and policy version per event |
| Callback handling | Provider webhook | Direct carrier or gateway | Signature validation and idempotency |
| Recovery | Vendor runbook | Your on-call rotation | Revoke, replay, and export drills |
What does “done” look like for authentication auditability?
Ask an investigator to answer three questions from a cold export: which risk event happened, which session action followed, and which policy version made the decision? The answer should be a query over immutable records, not a hunt through five dashboards.
Test the awkward paths: duplicate callbacks, expired codes, device changes, clock skew, partial database writes, and an operator-forced revocation. Measure export completeness, correlation coverage, and time to reconstruct one login. I’m not sure any team can predict every carrier quirk, so leave a quarantine queue for events that fail validation rather than silently dropping them.
An authentication audit trail earns its keep when it preserves context under pressure. If the ledger cannot explain a session lifecycle action, the system is not auditable yet.
Top comments (0)