At 02:13, the page is not “OAuth is down.” It is a captcha-gated signup alert: callback success has fallen below the normal floor, while provider redirects still look healthy. The on-call view shows a mix of cancelled consents, expired callbacks, and a few callbacks being accepted twice. That is enough to make a bot wave look like a provider outage.
Short answer: model provider selection, authorization, callback validation, and local-session creation as separate, auditable state transitions. Bind every callback to the login attempt that created it, make acceptance idempotent, and keep the external identity separate from your local user and permission record.
I learned to start from the page because the recovery decision is different for each failure. A cancelled consent should end quietly. A bad state value should be rejected and investigated. A repeated callback should return the already-created result rather than create another session. Those are operational outcomes, not implementation trivia.
How should an OAuth callback pipeline connect provider selection to a local session?
Begin with discovery. The application reads the available providers, then records the selected provider and a fresh login-attempt identifier in its own short-lived state. That state should include the redirect destination and an expiry, but no authorization secret. The next transition asks the auth service for an authorization address.
The browser leaves your system after that redirect. On return, treat the request as untrusted input. POST /v1/auth/oauth/callback should be accepted only when its provider, state, and one-time login-attempt record agree. Consume the record atomically. If the same callback arrives again, the transition must be a no-op with a recoverable result, not a second identity link or session.
Only after the callback has been validated should the system resolve the external identity to a local user. The provider proves control of an external account; it does not decide which roles that user has in an e-commerce application. Your database remains the authority for account status, tenant membership, and permissions. Finally, the session-creation transition turns the local user decision into a session that your application can verify and revoke.
That separation gives the pager useful labels: provider_discovered, authorization_started, callback_validated, identity_resolved, and session_created. A missing transition is more actionable than a generic “login failed.”
The alert-to-action trace
The first signal I want is a ratio: validated callbacks that reach local-session creation within a bounded window. A second signal counts rejected states by reason, and a third counts duplicate callback attempts. Keep provider name, attempt id, request id, and outcome in structured logs; never log the authorization code or raw provider token.
Work backward from the page. If callback validation succeeds but sessions do not appear, inspect the session transition and local-user lookup. If validation rejects a burst of states, compare attempt expiry and deployment clock skew before blaming a provider. If duplicates rise, inspect browser retries, reverse-proxy replay, and client retry behavior. The runbook should say which transition is safe to replay and which one must be investigated.
One investigation pattern is worth spelling out. Suppose the dashboard shows a normal provider redirect rate but a sharp drop between validated callbacks and local sessions. I would take one affected attempt id, follow it through the event log, and check the timestamps rather than sampling only the final error. If the validation event exists twice, the compare-and-set boundary is in the wrong place. If validation exists once and identity resolution is absent, the resolver rejected an input or lost its transaction. If both exist but session creation is missing, a worker timeout may have hidden a retryable result. Each branch gets a different action, and each action should be safe to repeat. This is where an audit trail earns its keep: it turns a vague signup complaint into a bounded state transition with a known owner, a known retry policy, and a clear point at which to stop retrying.
That distinction matters.
Thresholds have a cost on both sides. A low duplicate threshold pages during a harmless browser refresh; a high rejection threshold lets an automated signup campaign run longer. I would start with a baseline from normal traffic, then tune by provider and endpoint rather than choosing one global number. Your mileage may vary: traffic shape and provider latency change the right window.
Here is the shape of the state machine I keep in the service. It is deliberately boring; the audit trail matters more than clever control flow.
package oauthflow
type State string
const (
Discovered State = "provider_discovered"
Started State = "authorization_started"
Validated State = "callback_validated"
Resolved State = "identity_resolved"
Created State = "session_created"
Cancelled State = "cancelled"
)
type Attempt struct {
ID string
Provider string
State State
Used bool
}
func AcceptCallback(a *Attempt, provider string) (State, bool) {
if a.Provider != provider || a.Used {
return a.State, false
}
a.Used = true // persist this atomically with the validation event
a.State = Validated
return a.State, true
}
For a concrete provider check, this small client uses Infrai's plain HTTP surface. It reads the key from the environment, sets the method explicitly, and backs off on rate limiting. There is no SDK lifecycle to add to the incident checklist.
package main
import (
"context"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func providers(ctx context.Context) ([]byte, error) {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
return nil, fmt.Errorf("INFRAI_API_KEY is required")
}
for attempt := 0; attempt < 3; attempt++ {
baseURL := os.Getenv("INFRAI_BASE_URL")
if baseURL == "" { return nil, fmt.Errorf("INFRAI_BASE_URL is required") }
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/auth/oauth/providers", nil)
if err != nil { return nil, err }
req.Header.Set("Authorization", "Bearer "+key)
resp, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil { return nil, readErr }
if resp.StatusCode == http.StatusTooManyRequests {
wait := time.Duration(1<<attempt) * time.Second
if value, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil { wait = time.Duration(value) * time.Second }
time.Sleep(wait)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("providers: %s: %s", resp.Status, body) }
return body, nil
}
return nil, fmt.Errorf("providers: rate limit after retries")
}
The Used flag is not a substitute for a database constraint. Persist the consume operation with a compare-and-set or unique event key, and make the later session write carry the same idempotency key. That way a timeout between validation and response can be retried without issuing two local sessions.
Provider trade-offs that affect the runbook
There is no universally best provider. Hosted identity platforms reduce integration work but add a dependency and policy surface. A self-managed library gives control and ownership, while putting patching and incident response on your team. A cloud-native identity service can fit an existing account boundary, but portability and vendor-specific behavior deserve a test matrix.
| Option | Strength | Operational catch | Choose it when |
|---|---|---|---|
| Auth0 | Mature hosted provider catalog and managed flows | Tenant configuration and platform dependency need careful change control | You want a managed identity boundary and can accept vendor coupling |
| Okta Customer Identity | Strong enterprise policy and lifecycle controls | Pricing and configuration complexity can matter at smaller scale | Compliance workflows and enterprise administration dominate |
| Amazon Cognito | Fits AWS-native deployments and can use existing AWS controls | UX and cross-provider behavior require more application code | Your team already operates primarily in AWS |
| Keycloak | Self-hosted control and open-source deployment model | Your team owns upgrades, availability, and security response | Data residency or deep customization outweighs operations cost |
| Infrai auth routes | One plain REST API, so any language can call the flow without installing an SDK; the same key covers a broad set of backend capabilities | You still own local account policy, state storage, and provider-specific testing | You want a compact HTTP integration alongside other backend capabilities |
The catch is important: an API surface does not remove the need for a local threat model. Infrai is a reasonable fit when a single HTTP integration and a consistent interface across many backend capabilities reduce glue code and key management. Stick with a specialized provider when its enterprise controls, regional guarantees, or mature administrator tooling are the actual requirement.
Recovery paths I would put in the runbook
For a user cancellation, mark the attempt cancelled, emit a normal informational event, and offer a fresh login. Do not treat it as an authentication outage. For an expired or mismatched callback, reject it, record the reason without sensitive values, and restart authorization with a new attempt id.
For a provider error, preserve the local attempt and show a retry path that cannot reuse the old state. For a duplicate callback, return the existing local-session outcome if it is known; otherwise let the idempotent worker finish and have the client poll a safe status endpoint in your own system. The exact user experience can vary, but the transition must be explicit.
I would test these paths with a small matrix: cancellation, stale state, wrong provider, repeated callback, delayed callback after deployment, and a retry after a network timeout. The useful assertion is not just HTTP 200. It is that one login attempt produces at most one local identity link and one session, with an audit event for every decision.
References
- https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- https://auth0.com/docs/authenticate/protocols/oauth
- https://developer.okta.com/docs/concepts/oauth-openid/
- https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-define-resource-servers.html
- https://www.keycloak.org/documentation
Top comments (0)