Skip to content

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 through op.ExternalStep).
  • Rule: decides whether a step is required for this attempt.
  • LoginFlow: one Primary step plus an ordered list of Rules.

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
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_values naming the class it needs (OIDC Core §3.1.2.1). The OP then runs an additional factor before issuing a fresh id_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.
  • amr claim: 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 ​

One primary step, then whichever rules fire
A login flow runs one primary step, then evaluates its rules. Each rule that fires contributes a further factor step; when none fires, the tokens are issued. Rules are checked in declaration order; a rule whose step already ran is skipped, and the loop repeats until none fires.Primary stepPrimaryPasswordEvaluate the rulesin declaration orderIssue the tokenswhen none firesRULEFIRES WHENADDSRuleClientthe client is the admin client→ StepEmailOTPRuleAfterFailedAttemptsfailed attempts ≥ 3→ StepCaptchaRuleRiskrisk score ≥ High→ StepTOTPRuleACRAAL2's acr URI in acr_values→ PrimaryPasskey
Each rule is a predicate over the attempt, and each step kind appears in at most one rule. After a step runs the rules are evaluated again, and a rule's answer does not depend on the other rules, so adding a fifth rule does not change what the other four decide.

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:

ConstructorFires 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 is op.StepCaptcha are 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.New rejects a flow that repeats one.
  • A rule with a nil When (including a bare op.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.
  • Primary must be able to identify the user. op.StepTOTP, op.StepEmailOTP, and op.StepRecoveryCode verify a user who is already known, so op.New refuses them as Primary; put them in a rule behind op.PrimaryPassword, op.PrimaryPasskey or an op.ExternalStep. op.PrimaryPasskey at Primary (or in a StepOneOf there) runs a passwordless login and must set RequireUserVerification, otherwise op.New fails.
  • WithLoginFlow may be called once.

Always TOTP ​

go
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 ​

go
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 ​

go
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):

go
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_values request, the first satisfied value is reported. When none is, the acr is the canonical URI of the level the login did reach, so the RP can see it fell short.
  • With an essential acr request that no requested value satisfies, the OP refuses with unmet_authentication_requirements.
  • A custom op.ACRPolicy gets the same treatment when Resolve returns ok=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:

go
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 amr carries what the step records: pwd for a password, otp for TOTP, recovery codes and email OTP, hwk for a passkey (swk too when the step does not require user verification). A RuleAlways(StepTOTP) no longer asks again on each single sign-on once the session holds otp; a password-only session is still asked.
  • The three otp factors 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 same amr and level. An op.StepOneOf counts as done when any of its steps would. op.ExternalStep and op.StepCaptcha leave no amr and are asked again on every login.
  • An RP that needs a fresh factor sends max_age or prompt=login, or requests an essential acr above the session's level.
  • A StepOneOf with IfNoneEnrolled: op.UnenrolledSkip does not block a user enrolled in none of its steps: the session is served, and prompt=none returns 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, and auth_time in the issued tokens alongside the step-up factor. Risk assessors see the session's methods, and rules see Primary as 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, not login.*.

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:

go
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:

StepWhat it verifiesStore type (field Store)
op.PrimaryPasswordUsername / email + passwordstore.UserPasswordStore
op.PrimaryPasskeyWebAuthn / passkey as the primary factorstore.PasskeyStore
op.StepTOTPRFC 6238 TOTP, AES-256-GCM at-rest secret encryptionstore.TOTPStore
op.StepEmailOTPEmail-delivered one-time codestore.EmailOTPStore
op.StepRecoveryCodeSingle-use recovery codesstore.RecoveryStore
op.StepCaptchahCaptcha / Turnstile / your verifiern/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.

go
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.