Sign-in Experience
Users authenticate on a hosted UI that is compiled into the server binary. It is a React
single-page app (TanStack Router, built with bun and Vite) embedded with go:embed and
served from the public listener's root. You deploy nothing extra to get it.
Screens
| Route | Screen | Backed by |
|---|---|---|
/login | Sign-in with email and password | POST /v1/auth/sign-in |
/register | Self-registration (works only when auth.registration.enabled is on) | POST /v1/auth/register |
/forgot-password | Ask for a password-reset link | POST /v1/auth/password-reset |
/reset-password | Set a new password from a reset link | POST /v1/auth/password-reset/confirm |
/verify-email | Follow an address-verification link | POST /v1/auth/verify-email/confirm |
/consent | Consent to the scopes a client asks for | /v1/auth/consent, /v1/auth/consent/decision |
/device | Enter, approve or deny a device user code | /v1/auth/device/verify, /v1/auth/device/decision |
/mfa | TOTP challenge | /v1/auth/mfa/totp/verify |
/mfa/recovery | Sign in with a recovery code | /v1/auth/mfa/recovery-code/verify |
/mfa/setup | TOTP enrolment and recovery codes | /v1/auth/mfa/totp/* |
/logout | Sign out and confirm an RP-initiated logout | /v1/auth/sign-out, /v1/auth/logout/confirm |
/account-locked | Shown for a soft-locked account, only when auth.brute_force.reveal_lockout is on | — |
/authorize sends the browser here whenever it needs the user, and the UI returns to
the authorization flow when it is done. Error screens cover interrupted flows, unknown
pages and server errors. When the server's answer carries a request ID, the error screen
shows it as a reference ID with a copy button. An administrator can look that ID up on
the dashboard's troubleshooting page to find the audit records of the failed request.
The sign-in screen links to /forgot-password and /register. While
auth.registration.enabled is off, which is the default, the registration form answers
that sign-ups are disabled.
Not available. The UI contains passkey screens, but they are parked: they have no route because the server has no endpoint behind them. Passkeys are tracked in #200–#204. A self-service account page for managing MFA does not exist either. The server endpoints for MFA status, disabling TOTP and regenerating recovery codes are implemented, but no screen calls them.
Password reset and address verification
Both work by an emailed, single-use link, so both need an email relay for the user's organization. Without one, nothing is sent.
Password reset. The user enters an address on /forgot-password. The server answers
202 and the screen confirms in the same way whether or not the address has an account:
the account is looked up, and the link queued, after the response. A link is sent only to
an account that may sign in. It opens /reset-password on the organization's issuer with
the token in the URL fragment, which never reaches a server log or a Referer; the page
removes it from the address bar. Setting a new password:
- spends the link and every other reset link the account holds,
- ends all of the user's sessions, the refresh tokens issued under them and any sign-in in progress,
- clears a soft lock from failed sign-ins, and
- marks the address verified, when the link went to the account's current address.
A link that has expired or was already used gets its own screen, with a way to ask for a new one.
Address verification. /verify-email spends the link on load and shows whether it
worked. A link verifies only the address it was sent to: if the account's address changed
since, it verifies nothing. A verified address is published as email_verified: true in
tokens and UserInfo. No screen in the sign-in app asks for a verification link, and
registration does not send one; a link is requested with
POST /v1/auth/verify-email/resend, which answers 202 whatever it comes to. An account
whose address is already verified is sent nothing.
| Key | Default | Meaning |
|---|---|---|
auth.password_reset.validity | 30m | How long a reset link works (1m to 24h) |
auth.password_reset.max_requests | 3 | Reset links one account is sent per validity window |
auth.email_verification.validity | 24h | How long a verification link works (1m to 7 days) |
auth.email_verification.max_requests | 3 | Verification links one account is sent per validity window |
Requests beyond max_requests send nothing and are answered the same. Every request
also counts toward the per-IP sign-in throttle (auth.brute_force.ip_*). Requests,
completions and refusals are recorded as auth.password_reset.* and
auth.email_verification.* audit events.
New-sign-in alerts
The server remembers the browsers and networks each user signs in from. A browser is recognized by a random marker in a long-lived, HttpOnly cookie; a network is the address's /24 (IPv4) or /48 (IPv6). Only digests are stored, at most 20 of each per user, and an entry unused for 180 days is forgotten. Administrators see a user's known browsers and networks in the admin API and the dashboard.
When a password sign-in, a second-factor step or a registration that signs the user in
comes from a browser or a network the user has not used before, the user is emailed
an alert. It names the time, the browser, the IP address and, with an optional
IP geolocation database, the city and
country. Its button links to the organization's /forgot-password page, so a user who
did not sign in can reset the password, which also ends every session. There is no alert:
- on an account's first recognized sign-in, when there is nothing to compare it with,
- for a browser that already caused an alert in the last 24 hours,
- for a user without an address, or an organization without an email relay.
The alert never delays or fails the sign-in. Each one queued is recorded as
auth.sign_in.alerted.
Account status
An administrator can set an account to active, suspended (optionally until a given
time) or blocked. A suspended or blocked account cannot sign in: the status is checked
at the password step and again when the second factor is entered, so suspending a user
mid-sign-in stops it. With auth.brute_force.reveal_lockout off, the default, such an
account is answered exactly like a wrong password. With it on, and only after the right
password, the screen says the account is unavailable and to contact an administrator.
The status is also checked on every token grant: see
Tokens & Sender Constraint. A suspended account also
receives no reset or verification link.
Languages
The UI is translated into English, German and French. It chooses the language in this order:
- the OpenID Connect
ui_localesparameter of the authorization request (the first supported tag), - a
lngorlangquery parameter, - a choice the user made in the language menu, remembered in the browser,
- the browser's languages.
If none of those is supported, it uses English. Tags match on the base language, so
de-CH picks German. The server does not read Accept-Language to choose the UI's
language, only an email's, and discovery does
not advertise ui_locales_supported.
Sessions
A successful sign-in creates a durable login session held in a __Host- prefixed
cookie (auth.session.cookie_name, default nauthera_session). The session has an
idle lifetime (auth.session.idle_ttl, 12h) and an absolute lifetime
(auth.session.absolute_ttl, 24h). The sign-in screen shows a Remember me checkbox, but
the server ignores it: every session gets the same idle and absolute lifetimes. Sessions
belong to one organization and are never shared across organizations. Cookie naming,
consent and logout are covered in Sessions, Consent & Logout.
Sign-in has two layers of defense:
- Throttles: a per-IP limit on all sign-in attempts and a per-identifier limit on
failed ones (
auth.brute_force.ip_*andidentifier_*). - Durable soft lock: a per-account lock after
max_failed_attempts(5) failures, lastinglockout_duration(15m). Backoff is on by default: each further failure doubles the next lock, up tolockout_max(1h). Settinglockout_backoffto false turns it off.
By default the UI does not reveal that an account is locked (reveal_lockout: false).
Defaults and responses are in Rate Limits & Lockout.
Branding
The UI is branded per OAuth client. A brand is stored against
(issuer, client_id). When the server serves the page, it looks up the brand for the
request's client_id and injects it into the HTML. With cache.enabled on, which an
issuer that is not loopback requires, it caches each lookup, hit or miss, for 30 seconds,
so a changed brand can take that long to appear. The UI then applies the brand with the
Astryx design system's defineTheme.
| Field | Purpose |
|---|---|
productName | Product name shown in the header |
logoUrl | Header logo (https only) |
mode | Default colour mode: system, light or dark |
layout | centered (default) or split with a hero pane |
hero | Hero content for the split layout: eyebrow, heading, subheading, imageUrl, side |
companyName, appName | Footer copyright holder; "Continue to …" label |
supportUrl, termsUrl, privacyUrl | Footer links (http or https) |
fonts | Up to 8 https stylesheet URLs. The UI loads only those from Google Fonts (fonts.googleapis.com, fonts.gstatic.com) and ignores the rest. |
theme | An Astryx defineTheme input: color, typography, radius, motion, tokens, components, onDark, onLight, syntax and an optional name. The UI sets extends and icons itself. |
The server checks that the document is a JSON object of at most 128 KiB, that logoUrl,
the footer links and fonts follow the rules in the table above, and that
hero.imageUrl is an https or data:image/ URL. It checks nothing else: it stores the
document and serves it as-is. The schema lives with the UI, so it can grow without server
changes.
Setting a brand
Today the only way to write a brand is the gRPC AdminService.UpsertBranding call.
nauthera-cli has no branding commands. Applying a brand from a
Branding manifest is planned (#404).
Limits
- There is no issuer-wide default brand, only per-client brands. A client without one gets the neutral theme.
- The server stores no uploaded assets: the logo is an https URL, and the hero image an
https or
data:image/URL. - Strings cannot be overridden per locale.
- The browser compiles the theme; the server does not.