Use case — Custom chooser UI
prompt=select_account has two separable concerns:
- the session semantics: the browser holds a chooser group with several signed-in accounts, and the account the user picks decides the next
sub; - the rendering surface: the page that lists those accounts and POSTs the chosen
SessionID.
Multi-account chooser covers the first. This page covers the second: use op.HTMLUI{Chooser: ...} for a branded, server-rendered account picker while the OP keeps state, CSRF, and the session switch.
Source:
examples/12-custom-chooser-uidemonstratesop.HTMLUI{Chooser: ...}on the built-in HTML pages. Compareexamples/13-multi-accountfor the JSON-driver / SPA path.
When to use it
| Need | Use |
|---|---|
| Keep the bundled chooser | no option; the built-in HTML pages render it |
| Change chooser HTML / copy / layout while staying server-rendered | op.WithUI(op.HTMLUI{Chooser: op.PageTemplate{Template: tmpl}}) |
| Render the chooser inside a SPA | op.WithUI(op.SPAUI{...}) or op.WithUI(op.DriverUI{Driver: interaction.JSONDriver{}}) |
| Change how accounts are grouped or switched | session-store / authenticator logic, not the template |
The Chooser template is deliberately narrow. It swaps the template only; the template cannot choose arbitrary subjects, mint sessions, or bypass the OP's state machine. To lay the template over a driver of your own, pass an interaction.TemplateOverlayDriver with that driver as Inner to op.DriverUI.
Template contract
The template receives interaction.ChooserTemplateData:
| Field | Purpose |
|---|---|
Accounts | live sessions in the chooser group, most recently used first; each has SessionID, Subject, DisplayName (the user's name claim, empty when unknown), and AuthTime |
StateRef | opaque interaction state reference that must be echoed back |
CSRFToken | token the OP validates on POST |
SessionIDField | form field name expected by the OP for the chosen account |
SubmitMethod | normally POST |
SubmitAction | interaction endpoint URL |
AddAccountURL | /auth URL that signs in another account into the same group; empty when the deployment requires PAR |
Minimal shape:
tmpl := template.Must(template.New("chooser").Parse(`
{{range .Accounts}}
<form method="{{$.SubmitMethod}}" action="{{$.SubmitAction}}">
<input type="hidden" name="state_ref" value="{{$.StateRef}}">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<input type="hidden" name="{{$.SessionIDField}}" value="{{.SessionID}}">
<button type="submit">Continue as {{if .DisplayName}}{{.DisplayName}}{{else}}{{.Subject}}{{end}}</button>
</form>
{{end}}
{{if .AddAccountURL}}<a href="{{.AddAccountURL}}">Sign in to another account</a>{{end}}
`))
provider, err := op.New(
/* required options */
op.WithUI(op.HTMLUI{
Chooser: op.PageTemplate{Template: tmpl},
}),
)The field names are part of the OP contract. Keep state_ref, csrf_token, and the field named by SessionIDField (session_id) in the submitted form. Render the "add account" link only when AddAccountURL is non-empty: under RequirePAR (for example FAPI 2.0) a bare query-string /auth URL would be refused, so the OP leaves the field empty.
Each op.PageTemplate has its own ContentSecurityPolicy field, so the chooser declares its policy separately from the consent one; the two templates may load from different origins. An empty value uses the strict default (default-src 'none'; style-src 'none'; frame-ancestors 'none'; base-uri 'none'), which blocks stylesheets and images too. A custom policy is normalized by interaction.NormalizeCSP when op.New runs: form-action is forbidden, frame-ancestors and base-uri must be exactly 'none' when present, and missing protections are appended. A refused policy fails op.New with interaction.ErrCSPNotPermitted. Add only the asset directives the template needs, and keep scripts off.
Flow
session_id from being anything other than one this browser was just offered.The template never performs the switch; it only returns the chosen session identifier. The OP checks that the identifier belongs to the active chooser group and refuses a foreign or expired session_id with a 4xx. If the chosen session expired or was signed out in the meantime, the RP receives login_required.
If the chosen session cannot serve the request (the request has prompt=login, the session is older than max_age, or its acr does not meet acr_values), the OP does not fail the request. It runs the login factors for that account again.
The picked account goes through the same LoginFlow rules, Decider and risk step-up as a returning session, so a rule or a denial applies to a pick as it does to any other sign-in. A pick that re-authenticates rotates the picked session record with the new auth_time, acr and amr.
Who owns the chooser
op.SPAUI makes the SPA own the chooser surface through the JSON state envelope. op.WithUI takes one mode per deployment, so the chooser owner follows from the mode you pass:
| UI owner | Value for op.WithUI |
|---|---|
| OP server-rendered HTML | op.HTMLUI{Chooser: ...} |
| SPA shell mounted by the OP | op.SPAUI{...} |
| Your own router serves the SPA | op.DriverUI{Driver: interaction.JSONDriver{}} |
Production notes
- Parse templates once at startup, not per request.
- Keep the CSP strict. The data can contain attacker-influenced labels, so rely on
html/templateescaping and avoid inline scripts. - Treat
SessionIDas an opaque value. Do not display it or build it from other fields. - Use
AddAccountURLas given. It carries private markers that make the next sign-in join the existing group; a hand-builtprompt=loginURL replaces the current account instead of adding one.
Read next
- Multi-account chooser — chooser group semantics and
Sessions.Switch. - SPA / custom interaction — JSON-driver ownership of the same prompt.
- Custom consent UI — the equivalent server-rendered template seam for consent.