Consent
Consent is the user's explicit "yes, this app may have these scopes." OIDC Core 1.0 §3.1.2.4 leaves the timing open; it only requires that the OP obtain an authorization decision before releasing information. This library shows a consent screen the first time a client requests a scope set, and remembers the answer.
Specs referenced on this page
- OpenID Connect Core 1.0 — §3.1.2.4 (consent), §3.1.2.1 (
prompt) - RFC 6749 — §3.3 (scope), §6 (refresh)
- RFC 7009 — Token Revocation (§2.1 cascade)
Mental model
- A grant is a record of "user U authorised client C for scopes S", keyed by the pair
(subject, client_id). - The first authorize flow that needs scopes outside any existing grant shows a consent prompt.
- Later flows inside the same scope set skip the prompt.
- Adding a new scope (a scope delta) brings the prompt back, asking only for the delta.
- Revoking a grant deletes the record, so the next authorize flow prompts again.
When consent is shown
On every authorize flow the OP evaluates the four cases below, in the order the figure shows.
| Case | Behaviour |
|---|---|
First-time grant — no prior Grant for this (subject, client_id) pair. | Prompt. The user sees the full scope list. On submit, the library writes a Grant row with the approved scopes. |
Scope delta — a Grant exists but the requested scope set adds a scope not previously approved. | Prompt, with the delta highlighted. The user can approve the delta, deny it, or deny everything. On approve, the row is updated. |
Forced re-prompt — the request carries prompt=consent. | Prompt regardless of grant state. OIDC §3.1.2.1 mandates this. |
Existing grant covers the request — Grant row already covers every requested scope and every requested authorization_details element, and prompt is absent, none, or login (never consent). | Skipped. The library proceeds straight to authorization-code emission. There is no dedicated "skipped" audit event. |
A grant does not have to come from the consent screen. A device-code, CIBA, or token-exchange redemption records a store.Grant for the client it serves, and /auth counts that grant as consent: a later request for the same scopes skips the screen.
Skipping consent does not skip authentication rules. Before minting from an existing session, /auth evaluates the LoginFlow rules and the Decider as if the chain had just completed Primary. If a RuleClient or RuleScope step-up applies, the request runs that step first, and prompt=none answers interaction_required instead. Both the covering-grant path and the first-party skip below go through this check.
prompt=none, prompt=login, prompt=consent — what's the difference?
prompt=none— "if the user is signed in and consent is already in place, complete the flow silently. Otherwise return an error":login_required,consent_required, orinteraction_required. Used by SPAs for silent re-auth.prompt=login— "force the user to re-authenticate, even if a valid session exists." Does not by itself force a consent re-prompt.prompt=consent— "force the consent screen, even if a grant already covers the scopes." Useful when the RP wants the user to re-confirm before a sensitive operation.
The library applies these per OIDC Core 1.0 §3.1.2.1. Combining prompt=none with prompt=login is rejected as invalid_request.
Re-checking coverage for the terminal subject
The initial coverage check uses the subject available when authorization starts. prompt=login, a stale max_age, or an RFC 9470 step-up can run authentication that ends with a different subject; the account chooser can also switch accounts. Before issuing an authorization code, the library rechecks the requested scopes and authorization_details against the terminal subject's own grant. If that grant does not cover the request, the earlier subject's grant cannot authorize it: the flow obtains consent for the terminal subject or ends without issuing a code.
Authentication context on consent-only issuance
When no credential factor runs and the live session still exists, the authorization code and grant preserve that session's auth_time, acr, and amr. Answering the consent prompt does not replace auth_time, and an empty factor set does not clear acr or amr. If the session has expired, was signed out, or switched away and no live context remains, the request is refused with login_required rather than receiving an invented authentication context.
First-party clients
Some embedders run a small set of trusted apps — a flagship web client, an internal admin console, the company mobile app — where the consent screen would only annoy the user. op.WithFirstPartyClients(ids ...string) lets the named client_id values skip the consent prompt, whether or not a Grant exists yet. Every listed ID must also be registered through op.WithStaticClients, and the skip applies only to statically seeded or admin-created clients, never to dynamically registered ones.
The skip applies only when all of these hold:
- The user has a session, or the request has to sign the user in first (no session,
prompt=login,max_age, or an ACR step-up). In the second case the grant is bound to the subject that sign-in establishes; the skip never invents a subject. The account chooser (prompt=select_account) and registration (prompt=create) still show the screen. - The request does not carry
prompt=consent, which always shows the screen. - The scopes do not include
offline_access, and the request carries noauthorization_detailsand no Grant Management action. Those always get an explicit screen. - The browser sends
Sec-Fetch-Site: same-origin, orsame-sitewith anOriginorRefererthat matches theredirect_uriorigin. A cross-site navigation never skips.
Two more notes:
- The skip is recorded. The library writes the
Grantrow anyway and emitsaudit.ConsentGrantedFirstParty, so the audit trail still answers "what scopes did this user end up authorising?" The only change is that the user did not see the screen. - FAPI 2.0 profiles refuse the option. Combining
WithFirstPartyClientswithprofile.FAPI2Baselineorprofile.FAPI2MessageSigningfailsop.New.
See the first-party use case for the complete wiring including the operator-side scope catalogue.
First-party is operator-asserted, not user-asserted
The user does not see "this is a first-party app" in the consent flow. The trust comes from the operator listing the client_id in op.WithFirstPartyClients(...). Do not list a client whose code path you do not control end-to-end — that turns the skip into an unannounced scope grant.
How the library invokes consent UI
When a prompt is needed, the library hands control to the embedder-supplied consent template (the Consent field of op.HTMLUI, passed to op.WithUI). The template renders HTML; the library handles state, CSRF, the __Host-oidc_csrf double-submit cookie, and persistence.
op.WithUI(op.HTMLUI{
Consent: op.PageTemplate{
Template: myConsentTemplate, // *template.Template
},
})The template receives the client (ID, display name, logo, and the other client metadata), the requested scopes (and any deltas relative to an existing grant), and the CSRF token to embed in the form. Scopes marked Required cannot be deselected. PageTemplate.ContentSecurityPolicy sets the page's CSP; the default forbids every subresource, so a branded template that loads a stylesheet, logo, or font must declare those origins. On POST from the form, the library validates the CSRF, parses the approved scope set, writes the Grant row, and continues the authorize flow. A tampered or stale submission is answered with a 4xx, and cancelling the screen redirects back with access_denied.
For RFC 9396 authorization_details, coverage uses deep equality: a new element or a changed member forces consent, even when the scope set is already covered. The built-in consent contract exposes scope approval; it has no separate per-detail approval or narrowing control. After ordinary consent, the requested details are persisted with the grant.
For SPAs that render consent client-side (e.g. inside a React shell that already drives the login form), use op.WithUI(op.SPAUI{...}) when the OP should also mount the shell and static assets. In that mode the browser lands on LoginMount/{uid} and the SPA fetches prompt state from LoginMount/state/{uid}. If your own router serves the shell, use the lower-level op.WithUI(op.DriverUI{Driver: interaction.JSONDriver{}}) path instead; then the state endpoint remains /interaction/{uid}.
WithUI takes one mode, so a SPA and a consent template are alternatives rather than a combination. See the SPA custom interaction use case for the route shapes and trade-off.
Consent revocation
A user or operator can revoke consent at any time. There are three routes, and they reach different amounts:
| Route | What it retires | Grant record |
|---|---|---|
/revoke (RFC 7009) with one token | That token. For a refresh token, its whole rotation chain and the access tokens issued under the grant; the grant's other refresh chains and unredeemed codes keep working. | Kept, so the next authorize flow still skips consent. |
Grant Management revoke (DELETE on the grant) | Every access token and refresh token issued under the grant. | Deleted. The next authorize flow finds no covering grant and prompts again. |
Provider.RevokeSubject(ctx, subject) | The same, for every grant the subject holds. Meant for disabling or deprovisioning a user. | Deleted. |
The three share one teardown, so JWT access tokens, opaque access tokens, and refresh tokens are retired the same way regardless of the entry point. Tokens issued by device-code, CIBA, and token-exchange redemptions sit under a grant and are reached too. Credentials from a custom grant are not, unless the handler records a grant.
Successful Grant Management revocation emits grant_management.revoked. The library does not emit a dedicated consent.revoked event for generic grant or token revocation. The end-session cascade (sessions and logout) uses the same teardown but leaves the grant records in place.
Audit events
The consent flow emits two consent-related audit events through op.WithAuditLogger:
| Event | Fires on |
|---|---|
audit.ConsentGranted | User submitted the consent form and a Grant row was written. |
audit.ConsentGrantedFirstParty | First-party auto-consent applied (no prompt shown). |
audit.ConsentGranted also covers an ordinary reconsent that adds a scope or rich authorization detail; the library does not emit separate audit.ConsentGrantedDelta, audit.ConsentSkippedExisting, or audit.ConsentRevoked events. A successful Grant Management revocation is reported as audit.GrantManagementRevoked.
Together the emitted consent events let an SOC dashboard distinguish "this user explicitly clicked through consent" from "this client got consent on a silent path that the operator pre-approved" — useful when reviewing why a particular Grant row exists.
Read next
- Use case: first-party clients — operator-side scope catalogue,
op.WithFirstPartyClients, and the audit posture. - Use case: custom consent UI — wiring
op.HTMLUI{Consent: ...}, the template contract, and CSRF handling. - Reference: audit events — every audit event the library emits, with the matching extras.