Use case — MFA / step-up
The login layer is built from three primitives that compose:
Step: verifies one factor (password, TOTP, passkey, email OTP, or your own throughop.ExternalStep).Rule: decides whether a step is required for this attempt.LoginFlow: onePrimarystep plus an ordered list ofRules.
Once the primary step succeeds, the OP walks the rules and runs the step of every rule that matches. "Password always, TOTP always" is one flow; "password always, captcha after 3 failures, TOTP when the risk score is high" is another.
Use this page when the login decision depends on more than "password accepted": mandatory MFA, a captcha for suspicious attempts, risk-based extra factors, or an RP that asks for a stronger acr. If you only need username and password, the default password primary step and the user-store pages are enough; adding rules before you need them mainly adds test and recovery surface.
Specs referenced on this page
- RFC 6238 — TOTP (Time-Based One-Time Password)
- RFC 8176 — Authentication Method Reference Values (
amr) - RFC 9470 — OAuth 2.0 Step-up Authentication Challenge (the resource-server half)
- WebAuthn Level 3 — Passkeys
- NIST SP 800-63B — Authenticator Assurance Levels (AAL)
- OpenID Connect Core 1.0 — §2 (
acr,amr,auth_time)
Vocabulary refresher
- MFA: multi-factor authentication. The user proves more than one factor (something they know, have, or are) before the OP issues tokens.
- Step-up: when an RP needs higher assurance for a sensitive operation, it sends
acr_valuesnaming the class it needs (OIDC Core §3.1.2.1). The OP then runs an additional factor before issuing a freshid_token. RFC 9470 standardises how a resource server asks the client to do this. - AAL (Authenticator Assurance Level): NIST's three-tier ladder. AAL1 is roughly a password, AAL2 a password plus a second factor, AAL3 hardware-backed proof of possession. Many OPs and RPs use these labels in
acr. amrclaim: RFC 8176 lists standard reference values (pwd,otp,mfa,hwk,face,fpt, ...) so RPs can audit which factors actually ran.
Sources:
examples/20-mfa-totp— password + always-TOTP;examples/21-risk-based-mfa— risk-driven step-up;examples/22-login-captcha— captcha after N failed attempts;examples/23-step-up— RFC 9470 ACR step-up;examples/27-durable-mfa-store— the shipped SQL factor stores, shared with the core OP tables in one database.
Composition
LoginFlow is a struct with a Primary step, a list of Rules, and two optional fields: Decider (a policy hook consulted before the rules, for policies a rule cannot express) and Risk (a RiskAssessor). Each Rule pairs a predicate with a step and is built with a constructor:
| Constructor | Fires when |
|---|---|
op.RuleAlways(step) | on every attempt |
op.RuleAfterFailedAttempts(n, step) | this attempt has at least n rejected submissions |
op.RuleRisk(threshold, step) | the assessor's score is at least threshold |
op.RuleNewDevice(step) | the assessor reports a new device |
op.RuleClient(clientID, step) | the request comes from that client |
op.RuleScope(scope, step) | the request asks for that scope |
op.RuleACR(acr, step) | acr is in the request's acr_values |
op.RuleWhen(pred, step) | your own predicate over op.LoginContext returns true |
Besides the request fields, op.LoginContext carries AAL (the level reached so far, the higher of a reused session's and this attempt's), SessionReused (an existing session stands in for Primary), and Identity.AuthenticationContext (amr, acr, and auth_time when a session is reused). A custom op.ACRPolicy's Resolve sees the same values.
How the OP evaluates them:
- After
Primary, the OP checks the rules in declaration order, runs the step of the first one that matches and has not run yet, then checks again. When none matches, authentication is complete. Rules whose step isop.StepCaptchaare checked before all the others whatever their position; rules of each kind keep their declared order among themselves. - Each step kind may appear in only one rule;
op.Newrejects a flow that repeats one. - A rule with a nil
When(including a bareop.Rule{Then: step}) always matches, and a predicate that panics counts as a match. A flaw in a predicate therefore demands the factor rather than skipping it. Primarymust be able to identify the user.op.StepTOTP,op.StepEmailOTP, andop.StepRecoveryCodeverify a user who is already known, soop.Newrefuses them asPrimary; put them in a rule behindop.PrimaryPassword,op.PrimaryPasskeyor anop.ExternalStep.op.PrimaryPasskeyatPrimary(or in aStepOneOfthere) runs a passwordless login and must setRequireUserVerification, otherwiseop.Newfails.WithLoginFlowmay be called once.
Always TOTP
import (
"github.com/libraz/go-oidc-provider/op"
)
flow := op.LoginFlow{
Primary: op.PrimaryPassword{Store: st.UserPasswords()},
Rules: []op.Rule{
op.RuleAlways(op.StepTOTP{
Store: st.TOTPs(),
EncryptionKey: keys.TOTPKey,
}),
},
}
op.New(
/* ... */
op.WithLoginFlow(flow),
)Captcha after N failed attempts
flow := op.LoginFlow{
Primary: op.PrimaryPassword{Store: st.UserPasswords()},
Rules: []op.Rule{
op.RuleAfterFailedAttempts(3, op.StepCaptcha{Verifier: myCaptchaVerifier}), // hCaptcha / Turnstile / etc.
},
}
op.New(
/* ... */
op.WithLoginFlow(flow),
)The captcha provider is configured only through the Verifier field of StepCaptcha.
RuleAfterFailedAttempts reads the count of rejected submissions in the current attempt, which comes from the LoginAttemptObserver (passed through op.WithLoginAttemptObserver). The observer receives one op.LoginAttempt per authentication attempt. Its Outcome is op.AttemptSuccess, op.AttemptFailure, or op.AttemptLocked. AttemptLocked means the brute-force gate ended the attempt without evaluating the submitted credential, so handle it separately from an ordinary failure.
Risk-based step-up
flow := op.LoginFlow{
Primary: op.PrimaryPassword{Store: st.UserPasswords()},
Rules: []op.Rule{
op.RuleRisk(op.RiskScoreHigh, op.StepTOTP{Store: st.TOTPs(), EncryptionKey: keys.TOTPKey}),
},
Risk: myRiskAssessor, // RiskAssessor field on LoginFlow
}
op.New(
/* ... */
op.WithLoginFlow(flow),
)The RiskAssessor runs once per login attempt, before Primary, and returns a RiskScore from a four-level ordered enum (RiskScoreNone < RiskScoreLow < RiskScoreMedium < RiskScoreHigh). Your assessor maps whatever your risk provider returns onto it. RuleRisk(threshold, step) fires when the score is at least threshold.
The assessor can also deny the attempt or require named factors outright. It is set as LoginFlow.Risk. Without an assessor the score stays RiskScoreNone, so a RuleRisk never fires; it does not raise an error.
ACR step-up
When the RP asks for a stronger Authentication Context Class, a RuleACR for that value runs its step. The canonical values are op.AAL1.ACRURI() (urn:mace:incommon:iap:bronze), op.AAL2.ACRURI() (urn:mace:incommon:iap:silver), and op.AAL3.ACRURI() (http://idmanagement.gov/ns/assurance/loa/4):
flow := op.LoginFlow{
Primary: op.PrimaryPassword{Store: st.UserPasswords()},
Rules: []op.Rule{
op.RuleACR(op.AAL2.ACRURI(), op.StepTOTP{Store: st.TOTPs(), EncryptionKey: keys.TOTPKey}),
},
}
op.New(
/* ... */
op.WithLoginFlow(flow),
)The rule is a plain match on the request's acr_values: it runs the factor you named, and the OP does not pick a factor for you. On a reused session the step counts as done when the session already carries it (see Rules on a reused session).
The issued id_token reports in acr only what the login actually reached. The ACRPolicy decides, and the default accepts a requested value when it is a canonical URI at or below the level the login reached:
- With a voluntary
acr_valuesrequest, the first satisfied value is reported. When none is, theacris the canonical URI of the level the login did reach, so the RP can see it fell short. - With an essential
acrrequest that no requested value satisfies, the OP refuses withunmet_authentication_requirements. - A custom
op.ACRPolicygets the same treatment whenResolvereturnsok=false.
Custom acr values
An acr that is not one of the canonical URIs is never satisfied by default. Declare it with op.ACRLevels, which maps each value to the minimum AAL that satisfies it:
flow := op.LoginFlow{
Primary: op.PrimaryPassword{Store: st.UserPasswords()},
Rules: []op.Rule{
op.RuleACR("urn:example:mfa", op.StepTOTP{Store: st.TOTPs(), EncryptionKey: keys.TOTPKey}),
},
}
op.New(
/* ... */
op.WithLoginFlow(flow),
op.WithACRPolicy(op.ACRLevels{"urn:example:mfa": op.AAL2}),
op.WithACRValuesSupported("urn:example:mfa"),
)Keys must be non-empty, values must be op.AAL1 through op.AAL3, and a key equal to a canonical URI must carry that URI's own level. Do not modify the map after passing it to op.New. With the default policy or op.ACRLevels, op.New also refuses an op.WithACRValuesSupported entry the policy cannot resolve, since advertising it would promise a value the OP can never report; a custom ACRPolicy is not checked.
A session records the canonical URI of the level it reached, and it satisfies acr_values (or an essential acr) when the ACRPolicy accepts one of the requested values for that level. With the op.ACRLevels above, an AAL2 session is reused for acr_values=urn:example:mfa and an AAL1 session re-authenticates.
A custom op.ACRPolicy has Satisfies(ctx, requested, internal, completed) consulted for that decision, with the session's level as internal and the step kinds the session stands in for as completed. When the response is served from the session, its Resolve receives the same list in LoginContext.CompletedSteps. A loose Satisfies therefore also lets existing sessions skip step-up.
Rules on a reused session
A request can often be served from an existing session without any login, for example when a cached grant covers the scopes or the client is first-party. Before the OP issues a code that way, it evaluates the Decider and the rules with Primary and the steps the session already stands in for taken as complete. If a rule demands a step, the OP runs the chain from the session's subject rather than issuing; with prompt=none it answers interaction_required.
Consequences for rule design:
- A rule's step counts as done when the session's
amrcarries what the step records:pwdfor a password,otpfor TOTP, recovery codes and email OTP,hwkfor a passkey (swktoo when the step does not require user verification). ARuleAlways(StepTOTP)no longer asks again on each single sign-on once the session holdsotp; a password-only session is still asked. - The three
otpfactors stand in for each other: a session made with email OTP satisfies a rule that names TOTP, because the ID Token reports them with the sameamrand level. Anop.StepOneOfcounts as done when any of its steps would.op.ExternalStepandop.StepCaptchaleave noamrand are asked again on every login. - An RP that needs a fresh factor sends
max_ageorprompt=login, or requests an essentialacrabove the session's level. - A
StepOneOfwithIfNoneEnrolled: op.UnenrolledSkipdoes not block a user enrolled in none of its steps: the session is served, andprompt=nonereturns a code. If the enrolment check fails, the step counts as required. - A step-up on a reused session keeps the session's
acr,amr, andauth_timein the issued tokens alongside the step-up factor. Risk assessors see the session's methods, and rules seePrimaryas completed. - Captcha rules and risk-driven rules do not apply to the reuse check; they run only inside a chain.
- Step-up factors on a reused session are audited as
mfa.*events, notlogin.*.
The resource-server side: op.StepUpChallenge
RFC 9470 has two halves. The OP half is above: honour acr_values and max_age on /auth and re-authenticate. The other half lives at the resource server: when an access token lacks the required strength or freshness for a sensitive call, the resource server answers 401 with a WWW-Authenticate: Bearer challenge carrying error="insufficient_user_authentication" and the acr_values / max_age it needs. The client then re-authorizes with those values, and the OP step-up above kicks in.
op.StepUpChallenge builds that header value. The OP never emits it, because token validation and the 401 belong to your resource server; the library only produces the challenge string:
maxAge := int64(300)
challenge := op.StepUpChallenge("api", []string{"urn:acr:high", "urn:acr:mfa"}, &maxAge)
// challenge == `Bearer realm="api", error="insufficient_user_authentication",
// acr_values="urn:acr:high urn:acr:mfa", max_age="300"`
w.Header().Set("WWW-Authenticate", challenge)
w.WriteHeader(http.StatusUnauthorized)An empty realm, an empty acrValues slice, and a nil maxAge are each omitted; the mandatory error="insufficient_user_authentication" is always present. acrValues is encoded as a single space-delimited quoted string, mirroring the acr_values request parameter.
One-time factors are single-use
The one-time factors — email-OTP (op.StepEmailOTP), TOTP (op.StepTOTP), and recovery codes (op.StepRecoveryCode) — are single-use under concurrency: a code cannot be accepted twice. The store enforces this with an atomic compare-and-set that returns ErrAlreadyConsumed on replay, so two racing requests presenting the same code cannot both succeed. A custom factor store must make these consume operations a CAS (the in-memory reference shows the shape).
Terminal factor failures answer HTTP 400, not 500: an expired or already-consumed one-time code, a lockout, a required reset, too many resends, a result naming a different subject than the one already bound, and an AAL3 factor completed without user verification. These are conditions of the submission, not server faults; the user can start a fresh attempt.
A second factor the user has not enrolled also ends with a 400: no TOTP record or an unconfirmed enrolment, no recovery codes, or no bound email address for email OTP. The OP does not skip the step, so a rule that applies to every user requires every user to be enrolled.
The AttemptsRemaining value on the recovery-code prompt is the remaining failed-submission budget. It is not a count of unconsumed recovery codes, and a driver must not use it to infer how many recovery codes remain.
Audit trail
Each factor outcome is emitted from the audit.* catalog. The primary factor uses audit.LoginSuccess and audit.LoginFailed; any additional factor, including a step-up on a reused session, uses audit.MFASuccess and audit.MFAFailed. The guess that triggers a lockout or a required reset is recorded as a failure; refusals while the account stays locked are not counted as failed logins. audit.LockoutStalled reports repeated contention on the cross-factor lockout counter that abandoned a failed attempt without counting it.
Password failures count against the same per-subject counter as TOTP, email OTP, and recovery codes (see op.WithAuthnLockoutStore). Once the subject is locked, even the correct password is refused with the same response as a wrong one, so the response does not reveal that the account exists. audit.LockoutEngaged is the operator's signal: it fires once, with the subject as ActorID, when a failed attempt newly engages the lock.
audit.MFARequired, audit.StepUpRequired, and audit.StepUpSuccess are reserved catalog names; the OP never emits them. The mfa.* events carry the same information without double-counting retries.
Each event carries the resolved factor (password, totp, passkey, ...) in extras, and goes through op.WithAuditLogger (a *slog.Logger). Emission is synchronous on the request goroutine, so the handler must not block and must be safe for concurrent use.
Where authenticators come from
The library ships ready-to-use steps for the common factors:
| Step | What it verifies | Store type (field Store) |
|---|---|---|
op.PrimaryPassword | Username / email + password | store.UserPasswordStore |
op.PrimaryPasskey | WebAuthn / passkey as the primary factor | store.PasskeyStore |
op.StepTOTP | RFC 6238 TOTP, AES-256-GCM at-rest secret encryption | store.TOTPStore |
op.StepEmailOTP | Email-delivered one-time code | store.EmailOTPStore |
op.StepRecoveryCode | Single-use recovery codes | store.RecoveryStore |
op.StepCaptcha | hCaptcha / Turnstile / your verifier | n/a (Verifier) |
The bundled adapters hand these out from their concrete *Store type (st.UserPasswords(), st.Passkeys(), st.TOTPs(), st.EmailOTPs(), st.RecoveryCodes()); they are not methods of the store.Store interface.
The storage behind each step is yours — the library never owns user records or password hashes. The reference inmem adapter is fine for examples and tests; in production you implement the op/store/* substores against your existing user table.
For a factor with no built-in step, implement op.Authenticator and wrap it in op.ExternalStep with a dotted KindLabel; Custom authenticator walks through it.
For account-management flows, use the companion packages rather than constructing credential records yourself. op/passkeykit runs the WebAuthn registration ceremony from the same PrimaryPasskey configuration used at login. op/recoverykit creates and replaces recovery-code batches; show Result.Codes once and never persist or log them. Email OTP is configured as op.StepEmailOTP in the LoginFlow. See 28-email-otp-recovery and 29-passkey.
Passkeys need a domain, not an IP
A WebAuthn Relying Party ID must be a domain name, and browsers reject an IP literal for it, so an OP developing against http://127.0.0.1 has no valid RP ID to pair with. Bind the issuer to localhost and opt in with op.WithAllowLocalhostIssuerForDev(), which admits the textual host in the issuer; localhost redirect URIs need op.WithAllowLocalhostRedirectURIs() as well. The carve-out is for a developer machine and nowhere else; production RPs belong behind https.
Setting AAGUIDAllowlist on op.PrimaryPasskey also switches the registration ceremony to direct attestation conveyance, because an AAGUID without attestation is self-asserted and a software authenticator could name an approved model. The allowlist therefore requires AttestationRoots (trust anchors for the attestation certificate chain; the OP does not fetch FIDO MDS), and op.New refuses an allowlist without them. Expect a user-agent attestation prompt; a registration whose chain does not verify is refused rather than matched. Leave the allowlist empty to keep the prompt-free ceremony.
RequireUserVerification: true applies to both passkey registration and login. The authenticator must perform a user-verification gesture such as a PIN or biometric; a presence-only registration or assertion is refused. User verification is read from the flags of each assertion, not copied from the stored credential. Without the setting, a presence-only assertion counts as one factor: amr swk, AAL1, and it does not reach AAL2. Keep this setting in the shared op.PrimaryPasskey configuration used by op/passkeykit, so a credential accepted during enrolment remains usable at login.
Enrolling a TOTP factor
op.StepTOTP verifies codes against a store.TOTPRecord the embedder has already persisted. The complementary registration path lives in the op/totpkit package: it owns secret generation, the otpauth:// provisioning URI rendered as a QR code, and the proof-of-possession step that marks an enrolment confirmed.
import (
"github.com/libraz/go-oidc-provider/op/totpkit"
)
// Construct one codec at startup; share the same key bytes with
// op.StepTOTP{EncryptionKey: keys.TOTPKey} so verify and enrolment
// produce / consume the same AES-256-GCM blob shape.
codec, err := totpkit.NewCodec(keys.TOTPKey /*, previousKey, ... */)
// 1. After primary auth succeeds, kick off enrolment.
pending, err := totpkit.NewEnrolment(codec,
user.Subject, // OP-internal stable user ID (bound as AAD)
"Example Identity", // issuer label shown by the authenticator app
user.Email, // account label shown beneath the issuer
)
// pending.OTPAuthURI — render this as a QR code in HTML
// pending.SecretBase32 — show this for "manual entry" UX
// pending.Record — sealed TOTPRecord, NOT yet persistable
// 2. Stash `pending` in a short-lived enrolment session (server-side
// row keyed by a cookie). Render the QR code and the manual-entry
// secret to the user.
// 3. The user types the code their authenticator app displays.
record, err := totpkit.Confirm(codec, pending, submittedCode, time.Now())
// On totpkit.ErrCodeRejected, render the form again — `pending` is
// unmutated and the user retries. ErrDecrypt fires when the key has
// rotated past the codec's retention window.
// 4. Persist the confirmed record. From this moment op.StepTOTP
// accepts codes against the same secret.
_ = storage.TOTPs().Put(ctx, record)totpkit deliberately stays out of the HTTP surface — the embedder owns the HTML, the QR rendering, and the enrolment session. Both NewEnrolment and Confirm bind the subject as GCM additional-authenticated-data, so a row exfiltrated from one user's enrolment cannot be replayed under a different subject. The verify path uses the same AAD shape, so the binding holds across both ends.
For demo / CLI-only enrolment (terminal QR rendering, pre-confirmed seed records), see examples/internal/seedkit — it sits behind a //go:build example tag so the QR rendering library never enters the host module's go.sum.
Source:
examples/23-step-up— in-process OP+RP demo that walks the full enrolment + RFC 9470 ACR step-up flow.