Use case — Account page
An account page is where a user changes a password, replaces an authenticator app, or revokes a device. These are the actions an attacker with a stolen session would try first, so the page should insist that the user signed in recently, and before swapping a second factor it should ask for a code from the factor being replaced.
This page shows how to build that on top of the OP without a new library surface: the account page is an ordinary OIDC client of the same OP, recent sign-in is requested with max_age, the API checks it with rs.RequireMaxAge, and totpkit.Verify re-proves a TOTP factor.
Specs referenced on this page
- OpenID Connect Core 1.0 — §3.1.2.1 (
max_age,prompt), §2 (auth_time) - RFC 9470 — step-up authentication challenge (
insufficient_user_authentication) - RFC 6238 — TOTP, §5.2 (a code must not be accepted twice)
Build it as a client of the same OP
The OP exposes no helper that reads its session cookie from your page, and that is deliberate. When the session is too old, the only way to refresh it is to send the user through /auth again, so the page needs the authorization round trip in any case. Register the account page as a client, public with PKCE for a browser app or confidential for a server-rendered one, and sign in with the code flow like any other relying party. See Client onboarding and Protect an API for the registration and for the API the page calls.
Ask for a recent sign-in
Before a sensitive action, check how long ago the user authenticated. The ID Token carries auth_time, and so does a JWT access token. If it is older than you accept, start a new authorization request with max_age set to that limit in seconds:
GET /auth?response_type=code&client_id=account-page&scope=openid&max_age=300&...The OP honours max_age (OIDC Core §3.1.2.1): a session older than the limit is not reused, and the user authenticates again in full (Sessions and logout). Add prompt=login when you want a fresh authentication regardless of age.
The page still verifies the result itself. After the callback, validate the ID Token with your OIDC client library, then compare:
const maxAgeSeconds = 300;
const age = Date.now() / 1000 - idToken.auth_time;
if (idToken.auth_time === undefined || age > maxAgeSeconds) {
// not recent enough: do not proceed
}A token without auth_time does not meet the requirement. The browser check only decides what the page shows; the API check below is the one that protects the data.
Enforce it on the API
The account API is a resource server. Put rs.RequireMaxAge next to the scope requirement, so a token minted from an old sign-in cannot reach the route no matter what the page does:
verifier, err := rs.New(rs.Config{
Issuer: "https://op.example.com",
Audience: "https://api.example.com",
})
if err != nil { /* ... */ }
account := verifier.Middleware(
rs.RequireScope("account:write"),
rs.RequireMaxAge(5*time.Minute),
)
mux.Handle("POST /api/account/totp", account(http.HandlerFunc(replaceTOTP)))When auth_time is older than the duration, or absent, the API answers 401 insufficient_user_authentication and the WWW-Authenticate challenge carries max_age in whole seconds (RFC 9470 §3). That is the page's cue to restart authorization with the same max_age, then retry the call with the new token. The OP half of this exchange is in MFA / step-up, and the verifier itself is described in Protect an API.
Re-verify a TOTP code before replacing it
A recent sign-in proves the session is fresh, not that the person holds the authenticator being replaced. totpkit.Verify checks a code against the user's confirmed TOTP record, outside the login flow:
func replaceTOTP(w http.ResponseWriter, r *http.Request) {
tok, _ := rs.FromContext(r.Context())
code := r.FormValue("current_code")
if !limiter.Allow(tok.Subject, clientIP(r)) { // your own rate limit
http.Error(w, "too many attempts", http.StatusTooManyRequests)
return
}
err := totpkit.Verify(r.Context(), codec, totpStore, tok.Subject, code, time.Now())
switch {
case err == nil:
// the user holds the current factor
case errors.Is(err, totpkit.ErrNotEnrolled):
// no confirmed factor yet: nothing to prove, this is a first enrolment
case errors.Is(err, totpkit.ErrCodeRejected):
http.Error(w, "code not accepted", http.StatusUnprocessableEntity)
return
case errors.Is(err, totpkit.ErrLocked):
http.Error(w, "try again later", http.StatusTooManyRequests)
return
default:
http.Error(w, "server error", http.StatusInternalServerError)
return
}
// Replace the factor: totpkit.NewEnrolment, show the QR code, totpkit.Confirm
// the first code from the new app, then persist the record with totpStore.Put.
}codec is the *totpkit.Codec built with totpkit.NewCodec from the same key the login step uses, and totpStore is the store.TOTPStore behind op.StepTOTP. The subject is the OP's internal user identifier. With pairwise subjects the sub in a token is the per-client value, so map it back before calling totpkit.
| Result | Meaning |
|---|---|
nil | A correct code that has not been used before |
totpkit.ErrCodeRejected | A wrong code, or a code already accepted. The two are not told apart, so the response does not reveal which codes were already used |
totpkit.ErrLocked | The record is locked after too many failures, or a reset is required |
totpkit.ErrNotEnrolled | There is no record, or it was never confirmed |
totpkit.ErrCodecRequired, totpkit.ErrStoreRequired | A nil codec or store, a wiring mistake |
| any other error | A store or decryption fault, wrapped |
Verify shares its state with the login step. A code accepted at sign-in cannot be submitted again here until the next 30-second step (RFC 6238 §5.2), so a user who has just logged in may need to wait for the app to show a new code.
What Verify does not do
Verify is bounded by the TOTP record's own counters and nothing else: 30 wrong codes in 24 hours lock the record for 1 hour, and 90 lock it for 24 hours. Its failures are written to the same record the login step reads, so a burst of wrong codes here also locks TOTP at sign-in. It does not take part in the cross-factor lockout configured with op.WithAuthnLockoutStore, which means that lockout neither counts these attempts nor blocks them. Keep the route behind rs.RequireMaxAge and add a rate limit of your own, keyed on the subject and the client address, as in the example above.
Read next
- MFA / step-up:
acrandmax_agerequirements from the OP side, and the TOTP login step. - Protect an API: the verifier, requirements and challenges used above.
- Sessions and logout: how the OP decides whether an existing session can serve a request.