Use case — i18n / locale negotiation
What is ui_locales?
OIDC Core 1.0 §3.1.2.1 defines a ui_locales parameter on the /authorize request. The RP sends a space-separated list of BCP 47 language tags (RFC 5646 — e.g. ja en-US) and the OP renders the login / consent UI in the highest-priority language it actually has translations for.
If the RP does not send ui_locales, you can still pick up the user's preference from a locale cookie (set by the OP itself) or the Accept-Language HTTP header (RFC 9110), falling back to a registered default locale.
Specs referenced on this page
- OpenID Connect Core 1.0 — §3.1.2.1 (
ui_localesrequest parameter) - RFC 5646 — Tags for Identifying Languages (BCP 47)
- RFC 9110 — HTTP Semantics, §12.5.4 (
Accept-Language)
Vocabulary refresher
- BCP 47 — The IETF language-tag scheme (RFC 5646). Tags look like
en,en-US,ja,zh-Hant-TW— language, optional script, optional region. The OIDCui_localesparameter is a space-separated list of BCP 47 tags in priority order. Accept-Language— A standard HTTP request header where the browser publishes the user's preferred languages withq=quality factors (ja-JP, ja;q=0.9, en;q=0.5). When the RP does not pin a language withui_locales, the OP can fall back to this signal.- ICU MessageFormat — A locale-aware string-formatting language used for the bundle values. It handles plurals, gender, number/date formatting in a way concatenation can't (
{count, plural, one {1 device} other {# devices}}).
Resolution chain
The OP picks the language for built-in consent / login screens from a priority chain:
ui_locales request parameter
→ locale cookie
→ Accept-Language header
→ registered default localeThe first signal that resolves to a registered locale wins.
Source:
examples/16-i18n-locale
Wiring
import "github.com/libraz/go-oidc-provider/op"
frBundle, _ := op.LocaleBundleFromMap("fr", map[string]string{
"login.identifier": "Identifiant",
"login.password": "Mot de passe",
/* ... */
})
brandJa, _ := op.LocaleBundleFromMap("ja", map[string]string{
"consent.title": "Acme へのアクセス許可", // override one key only
})
op.New(
/* required options */
op.WithDefaultLocale("fr"),
op.WithLocale(frBundle), // register a new locale
op.WithLocale(brandJa), // overlay one key on top of the seed ja bundle
)The library ships seed bundles for English (en) and Japanese (ja) out of the box. op.WithLocale is layered, not replacement: each call merges on top of any earlier bundle for the same locale at key granularity. Embedders supply only the keys they want to change, and every unsupplied key falls through to the previous layer (the seed catalogue, or an earlier op.WithLocale call). Repeated calls for the same locale compose by repeated merge, so the last call wins per key.
Unknown locales (e.g. fr, de) register fresh on first call and merge on subsequent calls. The resolver still falls back to the configured default locale (see op.WithDefaultLocale) for any key the new locale's bundles never supply, so it is safe to ship a partial overlay without re-translating the whole catalogue.
The bundle format is ICU MessageFormat (passed through LocaleBundleFromMap for compile-time validation).
Verifying
# Discovery advertises the registered locales
curl -s http://localhost:8080/.well-known/openid-configuration | jq .ui_locales_supported
# ["en", "ja", "fr"]Drive the priority chain manually:
| Request | Rendered locale |
|---|---|
| No signal | fr (registered default) |
Accept-Language: es | fr (es not registered → fallback) |
Accept-Language: ja-JP, ja;q=0.9 | ja |
?ui_locales=ja en (RFC 5646 list) | first match = ja |
?ui_locales=ja + cookie set to fr | ja (parameter outranks cookie) |
What gets translated
| Surface | Bundle key example |
|---|---|
| Login form labels | login.identifier, login.password, login.submit |
| Consent prompt | consent.title, consent.scope.profile.description |
| Error pages | error.invalid_request_uri, error.unsupported_response_type |
| Logout confirmation | logout.confirm |
The keys follow the surface they render. New keys are added in minor releases — listing keys is part of the release notes when bundles change shape.
Custom UI takes over
If you ship op.WithSPAUI(...) or op.WithConsentUI(...), your templates are responsible for i18n. The library still negotiates the locale and exposes it through the interaction context, but the seed bundles only feed the built-in templates.
Read next
- Custom consent UI — when bundle keys are no longer sufficient.
- SPA / custom interaction — full SPA handles its own i18n.