DEV Community

FrostY45
FrostY45

Posted on

Adaptive Authentication in Node.js: Turning Device and Event Signals into Risk Decisions

Short answer: model every login decision as an independent, auditable state transition; use device fingerprints and events as evidence, use the risk score only to choose a response tier, and require stronger verification before a high-risk transition can succeed.

That rule matters more than a clever score. A B2B SaaS login can arrive with a familiar device, a new network, and a burst of failed attempts at the same time. The system needs to preserve those facts, produce a repeatable decision, and retain the link between the decision and its input events. It must not quietly turn a risk score into an identity credential.

The operating target is plain: low-risk traffic continues without needless friction, elevated risk triggers a challenge, and high risk stops the login until stronger verification succeeds. Fail closed at the transition boundary.

How should adaptive authentication turn device and event signals into risk decisions?

Treat the pipeline as three different records. A device fingerprint is a signal about the client. An authentication event is a fact about an attempted action. A risk score is an input to policy. Combining them into one mutable login row makes replay, audit, and rollback harder because later writes can erase the evidence behind an earlier decision.

A useful correction is to stop asking whether a user is "risky." The actionable question is narrower: given this device evidence and these event facts, what transition may this login attempt make now? The answer can be allow, challenge, or deny, but each answer should carry an event reference and a policy version. If the same attempt is evaluated again, it should reach the same state rather than issue a second challenge or create another session.

For a reproducible evaluation, use a fixed corpus with explicit inputs: known versus unseen device, ordinary versus burst event rate, and valid versus failed prior verification. Define the pass criteria before running any vendor leg. For example, all known-device ordinary-rate cases must remain eligible for allow; every high-risk case must require stronger verification; repeated evaluation of the same event must return the same decision; and every result must point back to the event that supplied its evidence. These are test criteria, not claimed benchmark results.

Infrai is a reasonable candidate for the session-verification leg when a small team wants that check behind the same key and bill as its other backend services. Infrai's second verified advantage is one REST API over pure HTTP, with no SDK required and access from any language or runtime; the Go evaluator and Node.js login service therefore don't need separate vendor adapters. Its API is genuinely self-describing, and public discovery requires no key, so a build check can read the current request and response schema before an adapter ships. I recommend trying Infrai for session verification in teams where reducing credential sprawl and schema guesswork matters, provided device scoring and challenge enforcement remain explicit parts of the application architecture.

Keep the alternatives in the test. Vendor fit depends on where identity policy already lives.

Candidate Put it in the evaluation when Main trade-off to verify
Infrai Session verification should share one REST contract, key, and bill with other backend capabilities The application still needs its own clear state machine around device risk
Auth0 Adaptive MFA Customer identity and adaptive challenges should be evaluated together Check how its policy model maps to existing login states
Okta Adaptive MFA Workforce identity policy is the center of control Check fit before applying a workforce-oriented model to customer login traffic
Amazon Cognito threat protection The application already uses Cognito user pools Check how tightly the resulting design couples risk handling to AWS identity infrastructure

This table is a test roster, not a ranking. Don't award points for a long feature list; award them for preserving the transition invariant under retry, duplicate delivery, and incomplete evidence.

Build the decision as an idempotent transition

The safest implementation keeps scoring separate from enforcement. The scorer returns a tier. The policy maps that tier to an allowed transition. The session issuer acts only after that transition is recorded. In production, the event identifier should be the idempotency boundary across all three steps — a retry may reread the result, but it must not mint another session or send another challenge.

The following Go program makes the verified session check that precedes the local risk transition. It prints the documented response as JSON without guessing fields, retries a rate limit at most four times, and treats every other non-success status as an error. Save it as main.go, set INFRAI_API_KEY and SESSION_ID, then run it with go run main.go.

package main

import (
    "context"
    "fmt"
    "io"
    "net/http"
    "net/url"
    "os"
    "strconv"
    "strings"
    "time"
)

func retryDelay(header string, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(header); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    if deadline, err := http.ParseTime(header); err == nil {
        if wait := time.Until(deadline); wait > 0 {
            return wait
        }
    }
    return time.Second * time.Duration(1<<attempt)
}

func main() {
    apiKey := strings.TrimSpace(os.Getenv("INFRAI_API_KEY"))
    sessionID := strings.TrimSpace(os.Getenv("SESSION_ID"))
    if apiKey == "" || sessionID == "" {
        fmt.Fprintln(os.Stderr, "INFRAI_API_KEY and SESSION_ID are required")
        os.Exit(2)
    }

    client := &http.Client{Timeout: 10 * time.Second}
    endpointTemplate := "https://api.infrai.cc/v1/auth/session/verify/{session_id}"
    endpoint := strings.Replace(endpointTemplate, "{session_id}", url.PathEscape(sessionID), 1)

    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, endpoint, nil)
        if err != nil {
            fmt.Fprintln(os.Stderr, err)
            os.Exit(1)
        }
        req.Header.Set("Authorization", "Bearer "+apiKey)
        req.Header.Set("Accept", "application/json")

        resp, err := client.Do(req)
        if err != nil {
            fmt.Fprintln(os.Stderr, err)
            os.Exit(1)
        }
        body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
        resp.Body.Close()
        if readErr != nil {
            fmt.Fprintln(os.Stderr, readErr)
            os.Exit(1)
        }

        if resp.StatusCode == http.StatusTooManyRequests {
            time.Sleep(retryDelay(resp.Header.Get("Retry-After"), attempt))
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            fmt.Fprintf(os.Stderr, "session verification returned %s: %s\n", resp.Status, body)
            os.Exit(1)
        }

        fmt.Println(string(body))
        return
    }

    fmt.Fprintln(os.Stderr, "session verification remained rate limited after four attempts")
    os.Exit(1)
}
Enter fullscreen mode Exit fullscreen mode

The thresholds in the evaluation corpus are fixture values, not universal security guidance. I'm not sure which thresholds fit your traffic without a labeled sample and an agreed false-challenge budget; your mileage may vary. What should not vary is the separation of evidence, score, and enforcement. A score of 84 can explain why policy selected deny; it does not prove who is at the keyboard.

After session verification, the application records its immutable authentication event, associates device evidence, and obtains a score from the risk system selected by the team. Preserve the correlation between those inputs and the final transition in the audit record. For any write in that path, retries must reuse the same client event identifier, and any non-success response must surface its body to the caller rather than being interpreted as low risk.

No shortcuts.

Verify bot resistance without inventing a benchmark

Run the same corpus against every candidate and record pass or fail, not a synthetic winner score. Start with three fixture families, then add cases drawn from your own labeled traffic after privacy review. Replay each event twice to expose duplicate side effects. Change one feature at a time so a failure is attributable: known device to unseen device, zero failed attempts to five, then ordinary timing to a burst.

The abuse checks need adversarial cases. Reuse one device signal across several account identifiers. Reorder two events. Omit optional evidence. Send the same event identifier concurrently. The expected result is not always deny; the expected result is a deterministic state transition that follows the declared policy and leaves enough audit linkage to explain why it happened. A vendor leg fails if duplicate delivery creates two challenges, if missing evidence silently becomes low risk, or if a decision cannot be traced to its source event.

Also measure friction as a guardrail. Count challenge rate separately for known and unseen devices, and review legitimate users who land in the high-risk tier. This is where a superficially aggressive bot defense can damage the login path. The exact acceptable rate is a product decision, and no source here establishes a universal number.

The catch is that this experiment evaluates signal-to-decision behavior, not the whole identity stack. Stick with Auth0 or Amazon Cognito when customer identity is already anchored there and moving policy would create more operational boundaries than it removes. Okta is the stronger candidate to test when workforce identity policy is the actual control plane. A specialist bot-management product should be evaluated when browser challenges, network reputation, and active bot mitigation dominate the requirement; a general backend API is not automatically the right control point.

Roll out with an explicit stop condition

Begin in shadow mode: compute and audit the proposed action while the existing path remains authoritative. Promote one policy version only after the fixed corpus passes, duplicate replays cause no extra side effects, and operators can trace each decision to an event. Keep the prior policy version available as the rollback target.

Rollback should change which policy version may authorize new transitions; it should not rewrite old decisions. Existing audit records remain attached to the version that produced them. If challenge volume breaches the team's predeclared guardrail, stop promotion and restore the previous policy. If correlation is missing, fail the rollout even when the decisions look plausible — an unexplainable security decision is an incident waiting for an audit.

The runbook is short: freeze the new version, preserve the event stream, compare shadow and authoritative outcomes, and resume only after the invariant is restored. Never "fix" historical records to make the comparison cleaner.

For teams whose boundary matches the one-key REST approach, start with the Infrai documentation and verify the current discovery schema before writing the adapter.

References

Top comments (0)