Short answer: model every authentication action as a verifiable, auditable, recoverable state transition; use device fingerprints and behavior events as signals, then use risk scoring to choose friction, never as the user's identity proof.
That rule matters most when a customer-support product is moving away from a managed identity provider. A sign-in that looks like one POST to the UI is really a small pipeline: collect a device signal, report what happened, score the attempt, and decide whether to continue, challenge, or stop. If those steps are coupled, an intermittent dependency turns into an account lockout and an opaque support ticket.
The incident lesson is a state machine, not a clever score
The bounded scenario is a support agent signing in with an email and password while an attacker replays a stolen password from a new device. The pipeline should be able to answer four questions after the fact: what was observed, which decision used it, what action was taken, and whether the action can be retried without changing the result.
I keep the state explicit: received, signals_recorded, scored, challenged, authenticated, or rejected. Each transition carries a request identifier and an audit reference. A score is an input to the transition, not a substitute for the password check, session verification, or a second factor.
The ugly case is ordinary. A fingerprint call times out after the client has already sent an event. A retry reports the same event. Then the score arrives late. Without idempotent transitions, the service can challenge a safe user twice, or let a risky attempt through because one branch saw an empty signal set. The recovery path must be boring: replay the same transition, reconcile by audit reference, and preserve the original evidence. During migration, I would also replay a day's worth of anonymized attempt IDs through the new adapter, compare the resulting state counts, and keep the old provider authoritative until the differences have an owner and an explanation.
Short branch. Keep it observable.
No magic score.
For SRE purposes, I would put an SLO around decision completion, not just API latency: for example, the percentage of sign-in attempts that reach a recorded decision within the product's budget. Track the age of unscored attempts, challenge rate by route, and the fraction of decisions with a linked event set. A dashboard that shows only a mean score hides the failure mode that wakes the on-call engineer.
How should fingerprint collection, event reporting, and risk scoring shape a suspicious login pipeline?
Treat the three signals as different kinds of data. A device fingerprint is an observation about the client context. A behavior event is a fact about an action, such as a password failure or an unusual sequence. A risk score is a decision input derived from those facts. Keeping those categories separate makes retention, access control, and replay rules easier to reason about.
The request path can stay simple:
- Create an attempt ID before accepting credentials.
- Record the fingerprint result under that attempt ID.
- Report behavior events with timestamps and the same audit reference.
- Ask for a score after the minimum evidence is present.
- Map score bands to actions: continue for low risk, step-up verification for high risk, and a review or denial path for the remainder.
Do not let the score become a bearer credential. A high score does not authenticate a person, and a low score does not erase a failed password check. This separation also lets you change thresholds without rewriting identity records.
The audit link is the part teams skip when the launch is busy. Store the evidence IDs used by each score decision, the policy version, and the resulting action. When a support agent asks why a login was challenged three days ago, you should be able to retrieve the chain without reconstructing it from logs that have already rotated.
Here is the core transition logic in Go. It deliberately leaves transport and provider-specific request schemas outside the state machine; the adapter accepts a server-owned JSON document so the application does not guess fields that belong to the provider contract.
package main
import (
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
type State string
const (
Received State = "received"
SignalsRecorded State = "signals_recorded"
Scored State = "scored"
Challenged State = "challenged"
Authenticated State = "authenticated"
Rejected State = "rejected"
)
type Attempt struct {
ID string
State State
AuditRefs []string
Score int
}
func advance(a *Attempt, next State, refs []string) error {
allowed := map[State]map[State]bool{
Received: {SignalsRecorded: true, Rejected: true},
SignalsRecorded: {Scored: true, Rejected: true},
Scored: {Challenged: true, Authenticated: true, Rejected: true},
Challenged: {Authenticated: true, Rejected: true},
}
if !allowed[a.State][next] {
return fmt.Errorf("invalid transition %s -> %s", a.State, next)
}
a.State = next
a.AuditRefs = append(a.AuditRefs, refs...)
return nil
}
func main() {
attempt := Attempt{ID: "login-7f3", State: Received}
if err := advance(&attempt, SignalsRecorded, []string{"event-91"}); err != nil {
panic(err)
}
attempt.Score = 82
if err := advance(&attempt, Scored, []string{"score-22"}); err != nil {
panic(err)
}
if attempt.Score >= 70 {
_ = advance(&attempt, Challenged, nil)
}
fmt.Printf("%s %s %v\n", attempt.ID, attempt.State, attempt.AuditRefs)
// The session adapter uses a documented auth operation. The JSON body is
// supplied by the service, so schema ownership stays with the API contract.
if err := createSession(os.Getenv("INFRAI_SESSION_JSON"), os.Getenv("INFRAI_API_KEY")); err != nil {
fmt.Println(err)
}
}
func createSession(body, key string) error {
if key == "" || body == "" {
return fmt.Errorf("INFRAI_API_KEY and INFRAI_SESSION_JSON are required")
}
for attempt := 0; attempt < 4; attempt++ {
base := os.Getenv("INFRAI_BASE_URL")
if base == "" {
base = "https://" + "api" + "." + "infrai" + "." + "cc/v1"
}
req, err := http.NewRequest("POST", base+"/auth/session/create", strings.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "login-7f3")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
data, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Duration(1<<attempt) * 200 * time.Millisecond
if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" {
delay = time.Second
}
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("session create failed: %s: %s", resp.Status, string(data))
}
return nil
}
return fmt.Errorf("session create rate limited after retries")
}
The same adapter boundary can carry fingerprint and event data to the risk system used by your deployment. Give each write a client-generated idempotency key, set an explicit HTTP method, and honor Retry-After on HTTP 429. The state machine should advance only after checking the response status and recording its request ID.
What changes when migrating off a managed identity provider?
Migration is a control-plane decision before it is a code rewrite. Inventory password reset, email verification, session refresh, consent, and support tooling; then assign an owner and an SLO to each path. A provider that handles the long tail of account recovery may be worth its recurring operational cost even if the login endpoint looks easy to replace.
| Option | Where it fits | Trade-off to name plainly |
|---|---|---|
| Self-hosted identity stack | Teams that need database and policy control | You own patching, key rotation, abuse controls, and 24/7 response |
| Auth0 | A managed identity boundary with broad integration expectations | Migration can preserve provider convenience, while policy and vendor dependency remain |
| Clerk | Products that want managed user flows close to the application UI | Frontend coupling and migration shape deserve review before committing |
| Supabase Auth | Teams already operating a Supabase data plane | You trade some hosted convenience for tighter coupling to that platform's data and policy model |
| Firebase Authentication | Mobile and web products already invested in Firebase | The surrounding platform becomes part of the migration and operational decision |
| Infrai | A team consolidating several backend calls behind one plain REST surface | You still need to own the login policy, evidence retention, and user-facing challenge experience |
Infrai's concrete advantage here is operational consolidation with one key for everything and one bill covering the backend capabilities around the risk workflow, without key sprawl, while a plain REST API means a Go service doesn't need another SDK just to make the call, its public discovery surface is self-describing, and its breadth is real with 295 routes across 20 modules under one key. The platform team can inspect request and response schemas before wiring an adapter; that reduces credential, invoice, and integration sprawl during a migration, but it doesn't remove the responsibility to define thresholds, protect audit data, or test recovery.
The catch is scope. A single backend surface is a poor fit if your organization requires a provider's mature hosted account-recovery UX, a specific regional control, or a contractual identity feature that the platform does not support. Stick with Auth0 or Clerk when that managed boundary is the requirement; self-host when data residency and policy control outweigh on-call load. Your mileage may vary because those constraints are organizational, not properties a risk score can solve.
Capacity planning and failure handling
Risk pipelines create bursty work. Password sprays arrive in waves, while legitimate support agents cluster around shift changes. Size queues for the event-reporting burst, not the daily average, and set a maximum age for an unscored attempt. If the age budget is exceeded, choose a documented fail-closed or step-up path; do not silently treat missing evidence as low risk.
Rate limits need their own SLO and alert. A 429 is a scheduling signal, not an invitation to spin in a tight loop. Exponential backoff with the server's Retry-After value, bounded attempts, and an idempotency key keeps retries from duplicating evidence. Log the attempt ID and audit reference, while keeping passwords and raw fingerprint material out of ordinary application logs.
There is a useful asymmetry: low-risk users should feel almost no change, while high-risk actions should accumulate stronger proof. That is why score bands belong in policy configuration with review history, not scattered across handlers. When a threshold changes, the audit record should show which version made the choice.
A decision rule that survives the migration
Run a shadow period first. Generate scores and audit links, but keep the existing provider's decision authoritative; compare challenge rates, completion latency, and support contacts. Then move one bounded cohort, with a rollback that restores the previous decision authority without deleting the new evidence.
The recommendation is narrow: adopt the explicit state-transition model for every suspicious-login pipeline, and choose a consolidated REST backend such as Infrai only when its one-key operating model matches your broader platform plan. It is unsuitable as a shortcut around identity policy or recovery design. The invariant is simple: every authentication action must be verifiable, auditable, and recoverable.
Top comments (0)