Use case — i18n / locale negotiation
What is ui_locales?
OIDC Core 1.0 §3.1.2.1 defines a ui_locales parameter on the /auth request. The RP sends a space-separated list of BCP 47 language tags (RFC 5646, for example ja en-US), and the OP renders the login and consent UI in the first language in the list that it has translations for.
When the RP sends no ui_locales, the OP falls back to a locale the user picked earlier (a saved preference or a cookie your language picker set), then to the browser's Accept-Language header (RFC 9110), and finally 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: a language, an optional script, an optional region.ui_localesis a space-separated list of them in priority order. Accept-Language: the HTTP request header in which the browser lists the user's preferred languages withq=weights (ja-JP, ja;q=0.9, en;q=0.5). The OP uses it when the RP does not pin a language withui_locales.- Message bundle: a flat map of string keys to strings. Values support simple
{name}placeholders; plural and gender formatting stay in your UI layer.
Resolution chain
The OP picks the language of its screens from a five-layer priority chain:
The first signal that resolves to a registered locale wins. The chain ends at the configured default, so the resolver always returns a registered locale.
Persisting an explicit locale choice
The OP reads the __Host-oidc_locale cookie but never writes it on its own, because it does not see the user's choice: a language picker is part of the UI you own. When your picker receives a choice, call Provider.SetLocaleCookie(w, locale). It canonicalizes casing and separators, accepts an exact registered tag or a language-only fallback (ja-JP matches a registered ja), and returns op.ErrLocaleNotRegistered without writing when nothing matches. Provider.ClearLocaleCookie(w) deletes the cookie, so the resolver falls through to ui_locales, Accept-Language, and the default. The cookie is Secure, HttpOnly, SameSite=Lax, and deliberately outlives the login session. Record only an explicit choice; copying whatever the chain resolved would turn one RP's ui_locales into the user's sticky default at every other RP.
Source:
examples/15-i18n-locale— boots a self-verify probe that drives every row of the chain through an in-process httptest server before opening port 8080, so the resolver behaviour is visible in the example output without a browser session.
Wiring
import "github.com/libraz/go-oidc-provider/op"
frBundle, _ := op.LocaleBundleFromMap("fr", map[string]string{
"login.title": "Connexion",
"login.identifier.label": "Identifiant",
"login.password.label": "Mot de passe",
"login.button.submit": "Se connecter",
/* ... */
})
brandJa, _ := op.LocaleBundleFromMap("ja", map[string]string{
"consent.title": "{client_name} へのアクセス許可", // override one key only
})
op.New(
/* required options */
op.WithDefaultLocale("fr"),
op.WithLocaleBundle(frBundle), // register a new locale
op.WithLocaleBundle(brandJa), // overlay one key on top of the seed ja bundle
)The library ships seed bundles for English (en) and Japanese (ja). op.WithLocaleBundle layers rather than replaces: each call merges onto the seed catalogue and any earlier bundle for the same locale one key at a time. Locale tags are canonicalized to lowercase, hyphen-separated BCP 47 form (pt_BR becomes pt-br). You supply only the keys you want to change; every other key falls through to the previous layer. Each key of a locale is single-valued: two WithLocaleBundle calls may supply different keys of one locale, but supplying the same key twice fails op.New.
A new locale (fr, de) registers on its first call and merges on later ones. A key the new locale never supplies falls back to the default locale (op.WithDefaultLocale), so a partial translation is safe to ship. op.New rejects a default locale that is neither a seed locale nor registered through WithLocaleBundle, and it rejects a WithDiscoveryMetadata(...UILocalesSupported...) entry for a locale you did not register.
op.Locale is a string type, so you can declare locale constants without importing internal packages. The seed locales are op.LocaleEnglish ("en") and op.LocaleJapanese ("ja"). LocaleBundleFromMap validates the map at construction time.
Per-user preferred locale store
A logged-in user's saved locale outranks every other signal. Implement op.PreferredLocaleStore and pass it through op.WithPreferredLocaleStore:
type pgLocaleStore struct{ /* db handle, cache, ... */ }
func (s *pgLocaleStore) PreferredLocale(ctx context.Context, sub string) (op.Locale, error) {
loc, err := s.fetch(ctx, sub)
if err != nil {
return "", err // a non-nil error is treated as "no preference" — chain continues
}
return op.Locale(loc), nil // empty Locale also falls through to the next layer
}
op.New(
/* required options */
op.WithPreferredLocaleStore(&pgLocaleStore{...}),
op.WithLocaleBundle(frBundle),
op.WithDefaultLocale(op.LocaleEnglish),
)PreferredLocale is consulted on every /auth hit, so the lookup must be cheap. Cache the value locally if your backing store is remote, because a slow call delays every login screen. Returning an error or an empty Locale both mean "no preference", and the chain continues.
Reading the resolved locale
The OP runs the chain for each prompt and exposes the result in two places.
From a SPA / custom interaction Driver
Every prompt envelope (GET LoginMount/state/{uid}, or /interaction/{uid} with the bare JSON driver) carries three locale fields, set before Driver.Render runs:
| Field | Use |
|---|---|
locale | The resolved BCP 47 tag. Stamp it onto <html lang> and pick translation bundles. |
ui_locales_hint | The RP's raw ui_locales list (whitespace-split). Read this only when overriding the resolver — most SPAs ignore it and trust locale. |
locales_available | The registered locales (the discovery ui_locales_supported list). Use it to build a language picker. The OP does not receive the pick: post it to an endpoint of your own and call Provider.SetLocaleCookie there. |
function applyLocale(prompt) {
if (prompt.locale) {
document.documentElement.lang = prompt.locale;
}
// bundles[prompt.locale] looks up the SPA's own message catalogue.
}Two more fields arrive already resolved in locale, so the SPA needs no translation table for them:
| Field | Use |
|---|---|
label_text on each entry of inputs, and on each entry of data.options in an auth.choose_factor prompt | The label key resolved in locale. Show it, and fall back on label (the i18n key) only when it is absent. |
error.message | The text of the rejection notice, resolved from the auth.error.<code> key. error.code is the stable identifier (credentials_rejected), so branch on the code and display the message. |
label_text and error.message are omitted when the OP was built without a locale resolver, and label_text is also omitted for a factor kind that has no catalogue entry. Prompt headings are not resolved: the SPA still maps type to its own heading. See SPA / custom interaction for the rejection notice itself.
The bundle in examples/10-react-login (shared from examples/internal/webui) demonstrates the prompt JSON contract; apply the locale handling above in your own SPA's message catalogue and document shell.
On the Go side the value is prompt.Locale, so a custom interaction.Driver can copy it into a header such as Content-Language or branch its rendering. examples/16-custom-interaction wraps JSONDriver to show the pattern.
From server-rendered code (emails, admin pages)
Provider.LocaleResolver() returns an *op.LocaleResolver for surfaces outside /auth (transactional emails, admin pages) that should use the same locale but have no prompt envelope:
loc := provider.LocaleResolver().Resolve(ctx, op.ResolveRequest{
Subject: userSub, // PreferredLocaleStore lookup
AcceptLanguage: r.Header.Get("Accept-Language"),
})
renderEmail(loc, body)LocaleResolver also has Default() (the fallback locale) and Available() (registered locales, seed first and then WithLocaleBundle additions). The provider builds it once and it is safe for concurrent use.
With a locale in hand, Message reads the merged catalogue (built-in strings plus your WithLocaleBundle additions), so a page you render yourself can reuse the OP's wording instead of keeping a copy:
title, ok := provider.LocaleResolver().Message(loc, "login.title", nil)
if !ok {
title = "Sign in" // key defined in neither the locale nor the default
}Lookup tries the exact locale, then its language subtag (ja-JP to ja), then the configured default locale, and finally the built-in English catalogue; ok is false only when none defines the key. Values in data replace {name} placeholders; an unknown placeholder stays visible as written.
The returned string is not escaped
Message returns plain text, which is what an email body, a JSON field, or a log line needs. An HTML template must apply its own contextual escaping. The bundled interaction.HTMLDriver escapes for you and reads from this same resolver, so WithLocaleBundle additions reach its titles, labels, and buttons; a key no catalogue defines falls back to built-in English.
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 |
Cookie __Host-oidc_locale=ja | ja |
?ui_locales=ja en (RFC 5646 list) | first match = ja |
?ui_locales=ja + cookie set to fr | ja (parameter outranks cookie) |
PreferredLocale returns ja, ?ui_locales=fr | ja (store outranks parameter) |
What gets translated
The bundled HTML driver looks up these keys. The seed en bundle (internal/i18n/embedded/en.json in the library repository) is the complete list; override any of them with WithLocaleBundle.
| Surface | Bundle keys |
|---|---|
| Login form | login.title, login.identifier.label, login.password.label, login.button.submit, login.username_hint |
| Second factors and captcha | auth.totp, auth.email_otp.send, auth.email_otp.verify, auth.recovery_code, auth.passkey, captcha, and the field labels (auth.totp.code, ...) |
| Consent | consent.title, consent.subtitle, consent.button.allow, consent.scope_required |
| Factor choice | auth.factor.password, auth.factor.passkey, auth.factor.totp, auth.factor.email_otp, auth.factor.recovery_code (the label_text of each option) |
| Rejected submission | auth.error.credentials_rejected (the error.message of a prompt, and the alert the HTML driver renders) |
| Account chooser | interaction.chooser, chooser.add_account, chooser.no_accounts |
| Logout | logout.confirm.title, logout.confirm.body, logout.confirm.body_current, logout.confirm.button, logout.title, logout.body |
Three cases fall outside the seed catalogue:
- Scope descriptions on the consent screen come from
op.Scope.Descriptionin its default language.Scope.I18nis stored but the consent prompt does not read it, so translate scope text in your own template or SPA, keyed by scope name. - OP error pages (the
#op-errorpage for a request that fails before a redirect is trusted) are English. - Prompts from your own authenticators use your keys: the HTML driver takes the page title from the key named after
Prompt.Type(for examplemyorg.sms.collect_phone) and each field label fromFieldSpec.Label. Add those keys withWithLocaleBundle; a key that no bundle defines is shown as written. See Custom authenticator.
New seed keys can arrive in a minor release; the release notes list them.
When a custom UI takes over
With the JSON driver or op.SPAUI, your SPA owns the strings. The OP still resolves the locale and stamps it on every prompt as locale, so the SPA does not re-run the chain, but the seed en and ja bundles feed only the bundled HTML screens.
UI options and locale
op.SPAUIand a customop.DriverUIdriver receive the resolvedlocaleon every prompt.- The
op.HTMLUILogintemplate receivesLocaleand the already-resolvedLabelTextof each field (interaction.LoginTemplateData), so one template can follow the locale chain. See Custom login UI. op.HTMLUIConsentandChoosertemplates receive no locale and no bundle (ConsentTemplateDataandChooserTemplateDatahave no such field), so each renders a single language. To serve several, use a driver or SPA that readsPrompt.Locale. See Custom consent UI.op.WithLogoutUItemplates receiveLocaleplus the already-resolvedTitle,BodyandButton(thelogout.*keys above), so a branded logout page follows the locale chain without a bundle of its own. See Brand the logout pages.
The full option list is in the options reference.
Read next
- Custom consent UI — when bundle keys are no longer sufficient.
- SPA / custom interaction — full SPA handles its own i18n.