Short answer: put five independent checks around the OAuth code exchange—CAPTCHA, a device fingerprint, event telemetry, a risk score, and step-up verification—and let the score decide when a Google or GitHub login needs another proof. This keeps a low-risk customer moving while giving the on-call engineer evidence when an account-takeover alert fires.
The page that wakes me up is rarely “OAuth failed.” It is usually a burst of successful logins from new networks, followed by a payout change. The alert arrives after the damage because the system measured only provider errors. A fintech login needs a trace from the first challenge to the final session, with a decision that can be replayed during a review. I don't want an incident bridge arguing over whether a browser looked “strange” while the only durable record is a 200 response; I want the attempt ID, the policy version, the exact signals, and the transition that created a session, all joined in one queryable trail so the first responder can make a containment decision in minutes.
No magic.
Start with the alert, then work backward
Imagine a 03:17 UTC page: 180 accounts authenticated in four minutes, 42 of them from never-seen devices, and the account-recovery queue is climbing. The first useful question is not which provider is down. It is which layer accepted the traffic, what signal it emitted, and why the next layer did not raise its hand.
Create one immutable authentication event for every attempt. Include a random attempt ID, user ID after account lookup, provider (google or github), IP prefix, device-fingerprint version, CAPTCHA result, risk-score version, and final action. Do not store raw OAuth tokens or a fingerprint as a permanent identity key. Hash or tokenize what you need for correlation, set retention with your privacy team, and make access auditable.
The signal that should have fired earlier is a change in behavior, not a single bad request. A new device plus an impossible travel jump plus five failed exchanges in ten minutes is a stronger signal than any one field. Keep the event stream append-only so a postmortem can compare the decision made at 03:10 with the policy deployed at 03:00.
Small details decide the outcome.
Here is a small Go contract for the decision record. It deliberately contains facts that an operator can inspect.
package auth
import "time"
type Action string
const (
Allow Action = "allow"
StepUp Action = "step_up"
Deny Action = "deny"
)
type LoginEvent struct {
AttemptID string `json:"attempt_id"`
Provider string `json:"provider"`
UserID string `json:"user_id"`
FingerprintVer string `json:"fingerprint_version"`
CaptchaPassed bool `json:"captcha_passed"`
RiskScore int `json:"risk_score"`
ScorePolicyVer string `json:"score_policy_version"`
Action Action `json:"action"`
OccurredAt time.Time `json:"occurred_at"`
}
The instrumentation change is simple: emit this event before creating a session and emit a second event when step-up verification succeeds or expires. Alert on rates and transitions, not just counts. A false positive has a cost too: a traveler with a new phone may face an extra check, and support will see that friction immediately.
What should CAPTCHA, fingerprints, events, scores, and verification do?
Treat the five layers as a chain with different jobs. CAPTCHA is a pressure valve for automation; it is not proof of human identity. A device fingerprint supplies continuity hints, but browsers can reset or disguise them. Events provide history. A score combines the evidence. Verification supplies a stronger factor when the score says the session deserves it.
For Google and GitHub, validate the authorization response exactly as the provider specification requires: check the state value, use PKCE for public clients, verify the token issuer and audience, and map the provider subject to an internal account. Never use an email address as the sole immutable key; account linking needs an explicit, authenticated flow. OWASP's Authentication Cheat Sheet is a useful baseline, but your threat model still decides the thresholds.
The order matters. Run a cheap, low-friction check first. If CAPTCHA fails, stop before the token exchange. If the fingerprint is unfamiliar, record it and continue unless other evidence raises risk. Only then calculate a score from a bounded set of features. Finally, choose allow, step_up, or deny, and make the action idempotent for the attempt ID.
One possible policy function is intentionally boring:
package auth
type Signals struct {
CaptchaPassed bool
NewDevice bool
RecentFailures int
TravelJump bool
}
func Decide(s Signals) Action {
if !s.CaptchaPassed {
return Deny
}
score := 0
if s.NewDevice { score += 25 }
if s.TravelJump { score += 35 }
if s.RecentFailures >= 3 { score += 30 }
if score >= 60 { return StepUp }
return Allow
}
Those numbers are placeholders for a policy test, not a universal truth. I am not sure a single threshold will fit a payroll portal and a consumer wallet; split policies by action and risk appetite, then version them. The important invariant is that a retry cannot turn step_up into allow merely because a worker restarted.
Make the OAuth exchange observable and repeatable
The provider callback should be a short transaction: validate state and PKCE, exchange the code, resolve the internal account, evaluate the five layers, and persist the event plus decision. Session creation happens once. Use a unique constraint on attempt_id and an idempotency key on the session write, so a duplicate callback cannot mint two sessions.
package auth
import (
"context"
"errors"
)
type CodeExchanger interface {
Exchange(ctx context.Context, provider, code, verifier string) (string, error)
}
type Store interface {
RecordDecision(ctx context.Context, attemptID string, action Action) error
CreateSessionOnce(ctx context.Context, attemptID, userID string) error
}
func Complete(ctx context.Context, ex CodeExchanger, st Store, attemptID, provider, code, verifier, userID string, signals Signals) error {
if attemptID == "" || provider == "" || code == "" || verifier == "" {
return errors.New("invalid callback input")
}
if _, err := ex.Exchange(ctx, provider, code, verifier); err != nil {
return err
}
action := Decide(signals)
if err := st.RecordDecision(ctx, attemptID, action); err != nil {
return err
}
if action == Allow {
return st.CreateSessionOnce(ctx, attemptID, userID)
}
return nil
}
In production, the exchange error needs a typed classification so rate limits, expired codes, and policy denials become different metrics. Never log the authorization code. Record a request ID and latency, and sample the full event only under a protected access path. During a postmortem, I want to answer “which policy version made this decision?” without reconstructing it from scattered logs.
Tune thresholds without paging every traveler
Start in shadow mode: calculate the score and verification action, but keep the existing session behavior while you compare outcomes. Have reviewers label a fixed sample of new-device and travel-jump events. Then choose thresholds against two explicit costs: account takeover exposure and added verification attempts. A dashboard that shows only the block rate hides both.
Use separate alerts for transport health and abuse pressure. Page on callback error rate, queue age for verification, and a sudden shift in score distribution. Send a ticket for a moderate rise in new-device events. This avoids waking someone for a marketing campaign while still catching a credential-stuffing wave.
The catch is that five layers are not suitable when your product cannot provide a recovery path or when privacy rules prohibit the required telemetry. In that case, reduce the fingerprint detail, retain fewer events, and rely on a stronger factor at every login; accept the friction explicitly. Stick with a simpler two-step policy when the account has no sensitive action and the support team cannot handle a verification queue. More signals do not automatically mean more safety.
| Failure mode | Observable symptom | Response |
|---|---|---|
| CAPTCHA over-triggered | Challenge rate rises for known customers | Recheck threshold and accessibility path |
| Fingerprint churn | Most devices appear new after a browser update | Version the signal; do not deny on it alone |
| Event loss | Decisions have no matching audit record | Fail closed for sensitive actions and repair the pipeline |
| Score drift | Distribution changes after a policy deploy | Shadow the new version and compare labeled samples |
| Verification backlog | Step-up queue exceeds its SLO | Rate-limit prompts and provide a staffed recovery route |
Close the loop with a runbook
When the page fires, freeze the policy version in the incident timeline, sample event IDs, and check whether the spike is isolated to Google, GitHub, a region, or a device version. Disable session creation for high-risk actions before disabling all login; that distinction protects customers who only need to view balances. Reprocess decisions by attempt_id, never by timestamp alone.
Afterward, write the smallest corrective change that would have fired the signal earlier: an event field, a missing join, a threshold experiment, or a runbook step. Test duplicate callbacks, expired PKCE verifiers, CAPTCHA timeouts, and verification retries in CI. The goal is a policy that can be explained six months later, not a clever score no one can audit.
Top comments (0)