DEV Community

SterlingVance2196
SterlingVance2196

Posted on

Adaptive Authentication State Design for Device Signals and Risk Decisions

Migrating a healthtech sign-in flow off a managed provider changes the engineering constraint: Google and GitHub callbacks must remain usable while every risk decision stays explainable after the fact. Short answer: model each authentication action as a state transition with explicit validation, recovery, and audit links; use device fingerprints and behavior events as evidence, and use the resulting risk score only to choose the next verification step.

The constraint is an auditable state machine

An OAuth callback is not an identity verdict. It is an input to a transition. A useful record has an action id, user and provider identifiers, the current state, the requested transition, the evidence event ids, and the policy version that made the decision. States such as received, validated, challenged, approved, and rejected make retries and recovery visible instead of implicit.

That distinction matters in healthtech. A clinician signing in from a recognized workstation may pass with a low-friction step, while a new device attempting a privilege-changing action should receive stronger verification. The score does not prove who the person is. It ranks the treatment of the action.

Keep the transition append-only. If a callback is delivered twice, an idempotency key derived from the provider event and action id lets the state machine accept one transition and record the duplicate as a harmless observation. This is the exactly-once mindset I use for ledgers: the write may be retried, but the audit trail must show one business effect.

Exactly once matters.

How should device, event, and risk signals shape sign-in decisions?

Treat the three inputs differently. A device fingerprint is a signal about continuity; a behavior event is a timestamped fact such as a failed challenge or an unusual location; a risk score is a decision input produced from those facts. Mixing these categories makes a later reviewer unable to tell whether policy saw evidence or merely trusted a number.

The ingestion order can be deterministic:

  1. Record the OAuth provider, callback nonce result, and action id.
  2. Submit the device fingerprint to the risk service.
  3. Report observable behavior to the risk service.
  4. Request a score, retaining the event references beside the response.
  5. Map score bands to a challenge, approval, or manual review transition.

Here is a minimal Go boundary for creating the resulting session. The payload fields shown are application-owned envelopes; the important contract is the explicit method and the stable action identity. In production, validate each response against the service schema before advancing state.

package risk

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "time"
)

type Client struct {
    BaseURL string
    HTTP    *http.Client
}

func (c Client) post(ctx context.Context, path string, body any) ([]byte, error) {
    b, err := json.Marshal(body)
    if err != nil {
        return nil, err
    }
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+path, bytes.NewReader(b))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", "auth-action-7f3c")
        resp, err := c.HTTP.Do(req)
        if err != nil {
            return nil, err
        }
        data, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Duration(1<<attempt) * 250 * time.Millisecond
            if retry := resp.Header.Get("Retry-After"); retry != "" {
                delay = 2 * time.Second
            }
            timer := time.NewTimer(delay)
            select {
            case <-ctx.Done():
                timer.Stop()
                return nil, ctx.Err()
            case <-timer.C:
            }
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("risk request failed (%d): %s", resp.StatusCode, data)
        }
        return data, nil
    }
    return nil, fmt.Errorf("risk request exhausted retries")
}

func (c Client) CreateSession(ctx context.Context, body any) ([]byte, error) {
    return c.post(ctx, "/v1/auth/session/create", body)
}
Enter fullscreen mode Exit fullscreen mode

The key must be supplied through the environment, and the client-supplied idempotency value must be stable for the same action, not generated anew on every retry. A fixed example value is intentionally illustrative; derive it from your durable action id in the real handler.

Comparing migration choices without outsourcing the decision

The managed provider you are leaving usually bundles callback handling, account linking, dashboards, and policy defaults. Replacing it with a homegrown service gives maximum control but also creates ownership for key rotation, consent records, replay protection, and incident evidence. A platform API sits between those extremes: it can reduce integration surface, but your policy and audit model remain your responsibility.

Option Strength for Google/GitHub migration Trade-off to record
Auth0 Mature hosted OAuth flows and account linking Provider-specific rules and migration coupling
Okta Customer Identity Strong lifecycle and enterprise controls Higher operational and configuration overhead
Firebase Authentication Fast setup and broad client support Backend policy and audit detail may need extra services
Self-hosted Ory Kratos Control over data residency and flows You operate upgrades, keys, and availability
Infrai risk API Plain HTTP calls for device, event, and score inputs; one key can cover backend capabilities You still need to define provider linking, state transitions, and compliance evidence

Infrai's relevant advantage here is the plain REST boundary plus one key and one bill across backend capabilities: a Go service can call it without installing or pinning an SDK, and the same HTTP convention works from another language if the migration later splits services. Its broader capability surface uses a consistent contract; the documented platform spans 295 routes across 20 modules under one key, so adding a neighboring backend function does not force a new client library or a second integration style. That can simplify credential rotation and month-end reconciliation for a small platform team already reconciling identity-provider tenants, risk telemetry, and clinical audit exports. It is an integration property, not proof that its score is correct for your population. Your model owner must test calibration, false-positive rates, and regional data handling.

The catch is fit. A team that needs a turnkey consent UI, tenant administration, or a provider's certified compliance package should stick with Auth0 or Okta. A regulated deployment with strict residency requirements may prefer self-hosted Ory, even though the platform work is larger. I'm not sure any vendor's default risk band should survive a clinical security review unchanged; treat it as a starting input and document the approval authority.

Audit links are part of the authentication result

For every transition, persist the evidence references that justified it: fingerprint record, event records, score request and response identifiers, policy version, verifier outcome, and timestamps. Store the reason for escalation in plain language, while keeping sensitive raw signals behind the access controls required by your retention policy. OWASP's authentication guidance is clear that recovery and session handling deserve the same scrutiny as the initial login.

Do not let a score become a bearer credential. A stolen or replayed score must not approve an action without a fresh, bound transition that checks the session, action id, evidence age, callback nonce, provider identity, and the exact policy version that produced the band; otherwise a perfectly valid score can be attached to the wrong session during a replay window. When a downstream write fails, mark the transition recoverable and retry the same idempotent operation; do not silently issue a second approval. Keep the raw event payload separate from the decision record so that a support engineer can inspect the reason without receiving more protected health information than the incident requires.

A staged rollout for the provider exit

Start in shadow mode: continue using the managed provider's decision while recording device and event evidence, then compare the proposed bands against reviewed cases. Next, gate only low-risk sign-ins and route high-risk cases to the existing challenge path. Finally, switch callback ownership after reconciliation proves that every approved action has a complete audit chain.

The rollout is complete when an auditor can replay a sample action from callback to final session without guessing which signal mattered. That test is more valuable than a dashboard of average scores.

Keep one uncomfortable case in the test set: a valid Google callback from a new device followed by a familiar GitHub account. It should produce a traceable challenge, not an opaque denial.

Sources

Top comments (0)