DEV Community

DimitriReed2158
DimitriReed2158

Posted on

Authentication Audit Trails: 5 Rules for Correlating Risk With Session Lifecycle

Authentication is a security control and an operational dependency. For an edtech sign-up and sign-in flow, the useful audit trail links a risk event to the session action it caused: challenge, creation, refresh, revocation, or denial. Short answer: keep one append-only event shape with a correlation ID, record the decision and its reason, and make every session mutation idempotent. This gives an incident responder a timeline instead of a pile of unrelated login messages.

I learned to care about this after being paged for missed jobs and duplicate deliveries in production. The symptom was never the first event; it was the action that happened twice, or the action that never happened. Authentication has the same shape. A password retry, a risk score change, and a revoked session can land in different services and different indexes. Without a shared identifier, the postmortem becomes guesswork.

No guesswork.

The incident lesson: link evidence before you tune detection

A bounded example is a student who signs in from a new device while a password spray is active against the same school domain. The risk service emits risk.assessed; the sign-in service returns a step-up challenge; the session service either creates a session after verification or records a denial. If those records use separate request IDs, support can see three events but cannot prove which decision produced the session.

The invariant is simple: every authentication attempt gets an attempt_id, and every event emitted because of it carries that value. A session also gets a session_id; it is created only after the password and any required challenge succeed. A refresh, logout, forced revocation, and expiry reference the same session. Do not infer lifecycle from timestamps or user-agent strings. Clocks drift, and user agents are shared evidence at best.

The first useful query is therefore a join on identifiers, not a search for similar text:

type AuthEvent struct {
    AttemptID  string `json:"attempt_id"`
    SessionID  string `json:"session_id,omitempty"`
    UserID     string `json:"user_id"`
    Kind       string `json:"kind"`
    Decision   string `json:"decision"`
    Reason     string `json:"reason"`
    OccurredAt time.Time `json:"occurred_at"`
}

func recordDecision(store EventStore, e AuthEvent) error {
    // The event key makes retries harmless during queue redelivery.
    return store.AppendOnce(e.AttemptID + "/" + e.Kind, e)
}
Enter fullscreen mode Exit fullscreen mode

That AppendOnce contract matters. A queue can deliver a revocation message twice; the audit writer must not turn that into two apparent revocations. I keep the original event and expose a duplicate counter as transport telemetry, rather than rewriting history.

What should an authentication audit trail connect across a session lifecycle?

Start with a small vocabulary that maps directly to state transitions. signup.accepted and signin.accepted describe identity proof, while risk.assessed describes context. challenge.required, challenge.passed, and signin.denied describe the decision boundary. session.created, session.refreshed, session.revoked, and session.expired describe lifecycle actions. A failed password check is an authentication outcome, not a reason to log the password or a password-derived value. OWASP recommends generic failure responses and careful handling of authentication errors to reduce account enumeration risk.

Each record should answer who, what, when, where, and why without collecting secrets. Useful fields include a stable user identifier, tenant or school identifier, source IP or a privacy-preserving representation, device context, authentication method, policy version, decision reason, and the actor that performed a revocation. Store timestamps in UTC and retain the original event time alongside ingestion time.

The correlation model can be represented as a table:

Field Meaning
attempt_id One sign-in or sign-up decision, including retries within that request
session_id One authenticated session, absent before creation
event_id Immutable unique record key
parent_event_id The event that caused this lifecycle action
policy_version Rules used to make the decision

Keep risk signals separate from verdicts. A signal such as impossible travel is evidence; challenge.required is the action selected by policy. This distinction lets you change thresholds without pretending that old events were evaluated by today's rules.

A preventative write path for email and password login

The write path should make the security-versus-friction decision explicit. A low-risk sign-in can create a session immediately. A high-risk sign-in can require a second factor or deny the attempt. In both cases, emit the risk assessment and the resulting action in order, using the same attempt ID. The session service accepts a client-supplied idempotency key scoped to the attempt, so a browser retry cannot create two sessions.

type LoginResult struct {
    AttemptID string
    SessionID string
    Action    string
}

func Login(ctx context.Context, email, password, attemptID string) (LoginResult, error) {
    user, err := users.FindByEmail(ctx, strings.ToLower(strings.TrimSpace(email)))
    if err != nil || !passwords.Match(password, user.PasswordHash) {
        _ = audit.AppendOnce(attemptID+"/signin.denied", AuthEvent{
            AttemptID: attemptID, Kind: "signin.denied", Decision: "deny", Reason: "invalid_credentials",
            OccurredAt: time.Now().UTC(),
        })
        return LoginResult{AttemptID: attemptID, Action: "denied"}, ErrUnauthorized
    }

    assessment := risk.Assess(ctx, user.ID)
    _ = audit.AppendOnce(attemptID+"/risk.assessed", AuthEvent{
        AttemptID: attemptID, UserID: user.ID, Kind: "risk.assessed", Decision: assessment.Decision,
        Reason: assessment.Reason, OccurredAt: time.Now().UTC(),
    })
    if assessment.Decision == "challenge" {
        _ = audit.AppendOnce(attemptID+"/challenge.required", AuthEvent{
            AttemptID: attemptID, UserID: user.ID, Kind: "challenge.required", Decision: "challenge",
            Reason: assessment.Reason, OccurredAt: time.Now().UTC(),
        })
        return LoginResult{AttemptID: attemptID, Action: "challenge"}, nil
    }

    sessionID, err := sessions.CreateOnce(ctx, user.ID, attemptID)
    if err != nil {
        return LoginResult{}, err
    }
    _ = audit.AppendOnce(attemptID+"/session.created", AuthEvent{
        AttemptID: attemptID, SessionID: sessionID, UserID: user.ID, Kind: "session.created",
        Decision: "allow", Reason: "policy_allow", OccurredAt: time.Now().UTC(),
    })
    return LoginResult{AttemptID: attemptID, SessionID: sessionID, Action: "session_created"}, nil
}
Enter fullscreen mode Exit fullscreen mode

The code intentionally returns a generic unauthorized error. Detailed reasons stay in the protected audit stream, where access is logged and reviewed. Audit storage must be access-controlled, encrypted in transit and at rest, and protected from edits by the application role. An append-only sink, retention policy, and alert on unexpected deletion are operational controls, not optional logging polish.

Comparing implementation boundaries without picking a winner

Managed identity services such as Auth0 and Okta commonly provide hosted authentication and event exports; the trade-off is that your correlation schema and retention behavior depend on their export model. Keycloak can be self-hosted and extended, which gives more control over event storage but leaves upgrades, capacity, and on-call ownership with your team. A custom service gives exact lifecycle semantics and identifiers, while making password handling, recovery, rate limiting, and patching your responsibility.

The catch is operational ownership. A hosted option is not suitable when policy requires keeping raw audit data inside a tightly controlled network or when you need database-level replay of every state transition. A self-hosted option is a poor fit for a small team that cannot staff upgrades and incident response. Stick with a custom implementation only when you can fund security review, key rotation, and abuse testing. Your mileage may vary; the right boundary follows your retention, residency, and staffing constraints, not a feature checklist.

Runbook checks and decision rule

During an incident, query by attempt_id, then follow parent_event_id to the risk assessment and session mutation. Verify that event time and ingestion time differ within an expected bound, that policy versions are present, and that a repeated delivery did not create a second session. A missing child event is an alert: it means the state change may have committed without durable evidence.

Test these paths before deployment: invalid credentials, challenge pass and fail, browser retry, logout replay, forced revocation, token refresh after revocation, and retention expiry. Include clock skew and queue redelivery in integration tests. Export counters for denied attempts, challenges, session creations, duplicate idempotency keys, and audit write failures; never put passwords, session tokens, or challenge answers in those metrics.

Use this decision rule: choose the simplest architecture that preserves a complete, queryable chain from risk evidence to session action, and reject any design that cannot explain a single login after a retry or revocation. Security gets stronger when the trail is boring.

References

Top comments (0)