Skip to content
go-oidc-provider
Main Navigation Why
Start here
Choose a configuration
Install
Minimal OP
Required options
Mount on your router

Overview

Reading order

Setup primitives

OAuth 2.0 / OIDC primer
Issuer
Redirect URI
Client types
Scopes and claims
Discovery
JOSE basics

Common flows

Authorization Code + PKCE
Refresh tokens
Client Credentials

Tokens

ID Token vs access token
Access token format (JWT vs opaque)

Sessions and consent

Sessions and logout
Consent

Sender-constrained tokens

Sender constraint (DPoP / mTLS)
DPoP
mTLS

Advanced topics

Device Code (RFC 8628)
CIBA
No-browser flows (CIBA vs Device Code)
Token Exchange (RFC 8693)
JARM (signed authorization response)
FAPI 2.0 primer

Help

Glossary
FAQ
All use cases

Bootstrap

Minimal OP and RP pair
Comprehensive bundle

Profile & flow

Declaring a security profile
FAPI 2.0 Baseline
FAPI 2.0 Message Signing
Service-to-service (client_credentials)
OAuth 2.0 (no openid)
DPoP nonce flow
Protected resource metadata (RFC 9728)
Protect an API (resource server)

Grants

Device Code (RFC 8628)
CIBA poll mode
Token Exchange (RFC 8693)
JWT bearer (RFC 7523)
ID-JAG (experimental)
Custom Grant

Crypto & subjects

Pairwise subject
JWE encryption

UI

SPA (custom interaction)
Custom login UI
Custom consent UI
Custom chooser UI
Multi-account chooser
CORS for SPA
i18n / locale

Storage

Choosing a storage layout
Persistent storage (SQL)
DynamoDB storage
Hot/cold split (Redis volatile)
Bring your own store backend

Scopes & claims

Public / internal scopes
Claims request
Rich authorization requests (RFC 9396)
Grant management
MCP authorization server

Authentication

MFA / step-up
Account page
Passwordless passkey
Bring your own user store
Migrating bcrypt passwords
Custom authenticator

Governance

First-party consent skip
Client onboarding patterns
Dynamic Client Registration
Back-Channel Logout

Operations

Prometheus metrics
Overview
Go-live checklist
Key rotation
JWKS endpoint
Multi-instance
Observability
Backup & DR
Storage maintenance
Shared Signals

Standards

RFC matrix
OFCS conformance
OFCS reproduce

Security

Posture
Design judgments
CVE regression matrix
Clock and replay windows
Disclosure
Options
Errors
Audit events
Architecture
Release Notes
Changelog

English

日本語

English

日本語

Appearance

Sidebar Navigation

Overview

Reading order

Setup primitives

OAuth 2.0 / OIDC primer

Issuer

Redirect URI

Client types

Scopes and claims

Discovery

JOSE basics

Common flows

Authorization Code + PKCE

Refresh tokens

Client Credentials

Tokens

ID Token vs access token

Access token format (JWT vs opaque)

Sessions and consent

Sessions and logout

Consent

Sender-constrained tokens

Sender constraint (DPoP / mTLS)

DPoP

mTLS

Advanced topics

Device Code (RFC 8628)

CIBA

No-browser flows (CIBA vs Device Code)

Token Exchange (RFC 8693)

JARM (signed authorization response)

FAPI 2.0 primer

Help

Glossary

FAQ

On this page

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.

Consent is shown on any of three triggers, else skipped
Three gates decide whether consent is shown: an explicit prompt=consent, no existing grant for this subject and client, or a requested scope outside the existing grant. If none of them fires, the existing grant already covers the request and the authorization code is minted without a prompt.the request carriesprompt=consentyesForced re-promptask again even if a grant covers itnono existing grant for(subject, client_id)yesFirst-time grantshow consent and record what was agreednoa requested scope is outside the grantthe existing grant does not cover ityesScope deltaconsent for the difference onlynoThe existing grant already covers the requestno prompt — the OP mints the authorization code and redirects straight back
Only the third gate looks at scope. That is why adding one scope to an existing integration re-prompts every user: the request no longer fits the grant they gave, and the OP will not widen a grant it was never asked about.
CaseBehaviour
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, or interaction_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 no authorization_details and no Grant Management action. Those always get an explicit screen.
  • The browser sends Sec-Fetch-Site: same-origin, or same-site with an Origin or Referer that matches the redirect_uri origin. A cross-site navigation never skips.

Two more notes:

  1. The skip is recorded. The library writes the Grant row anyway and emits audit.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.
  2. FAPI 2.0 profiles refuse the option. Combining WithFirstPartyClients with profile.FAPI2Baseline or profile.FAPI2MessageSigning fails op.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.

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

RouteWhat it retiresGrant record
/revoke (RFC 7009) with one tokenThat 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:

EventFires on
audit.ConsentGrantedUser submitted the consent form and a Grant row was written.
audit.ConsentGrantedFirstPartyFirst-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.
Pager
Previous pageSessions and logout
Next pageSender constraint (DPoP / mTLS)

a personal project by libraz