Skip to content

Authentication and users

BoardGameTracker ships with local accounts. Authentication is on by default and every request to the API needs a signed-in user, except signing in, the health check, game covers and the public RSVP page for game nights.

A user named admin is created on the first start. Its password comes from the ADMIN_PASSWORD environment variable; when that is empty the password is admin. Sign in, open Settings → Account and change it straight away.

The admin account is recreated with the same rule after a factory reset (Settings → Advanced → Danger zone), so set ADMIN_PASSWORD if you rely on that.

Role Can
Admin Everything: manage users, change settings, reset data.
User Add and edit games, sessions, players, locations, loans and game nights; upload rulebooks; use the rules assistant.
Reader Look around: every page is read-only, the add/edit/delete buttons are hidden and the rules assistant is not available.

Users are managed under Settings → Account → User management (admins only). Each user can be linked to a player, which the dashboard uses for the “welcome back” card and personal statistics.

  • An admin can reset any password from the user list; a temporary password is shown once.
  • When email is configured, the login page shows a Forgot password link that mails a reset link. The link uses the Public URL setting under Settings → General, so set that to the address your users reach the app on; without it no reset email is sent.

Local password login also works over plain HTTP, so a home install reached as http://192.168.1.20:5444 keeps working without certificates. The password then travels unencrypted, so anyone who can watch the network traffic can read it. Put any install that is reachable from outside your own network behind a reverse proxy with TLS and use the https address. Single sign-on is stricter and needs HTTPS, see below.

Set AUTH_ENABLED=false to turn authentication off. Every visitor is then treated as an administrator, and the application prints a warning at startup to remind you of that.

Logins use short-lived access tokens (15 minutes) and refresh tokens (7 days) signed with JWT_SECRET. The access token is kept only in the browser’s memory. The refresh token is an HttpOnly cookie named bgt_refresh, scoped to /api/auth, SameSite=Lax and marked Secure on HTTPS requests; the server stores it hashed. If a replaced refresh token comes back while its successor was never used (a refresh response lost to a reload or a flaky connection, or two tabs refreshing at once), the session continues with a fresh token. If a later token of that session was already used, the old token counts as stolen and every session of that user is revoked. Once a session ends, by signing out or by a revoke like this one, its access tokens stop working at once; other devices stay signed in after a sign-out. Changing JWT_SECRET signs everyone out.

Player photos under /images/profile are only served to signed-in browsers: logging in sets an HttpOnly cookie scoped to that path (it lives as long as the refresh token and is cleared on logout), and API clients can pass their bearer token instead. Game covers stay public. The public RSVP page therefore shows initials instead of photos.

Five failed logins for one username from one address block that username from that address for 15 minutes after the last failure (IPv6 clients are grouped per /64 network). On top of that, one address can try 10 logins per minute. After 20 failures for one username across all addresses, that username gets one attempt every 30 seconds until the failures age out; the account is slowed down, never locked. Behind a reverse proxy this only works when TRUSTED_PROXIES is set.

One generic OpenID Connect provider (Keycloak, Authentik, Authelia, Zitadel, Entra ID, …) can sit next to local accounts. The flow is the authorization-code flow with PKCE, exchanged by the backend: the browser is sent to the provider, the provider sends it back to the backend, and the backend hands the session to the app without ever putting a token or code in a URL the app sees.

Single sign-on only runs over HTTPS. Starting a sign-in or a link and both callbacks are refused over plain HTTP: the login page shows Single sign-on requires HTTPS and hides the provider button, and the link button under Settings → Account is hidden as well. The only exception is a loopback address (localhost, *.localhost, 127.0.0.1, [::1]) for development, which browsers treat as secure too.

The scheme is the one the browser used. Behind a reverse proxy that terminates TLS the application only sees it when TRUSTED_PROXIES contains the proxy and the proxy sends X-Forwarded-Proto; without that every request looks like plain HTTP and single sign-on is refused.

Under Settings → Single sign-on the provider cannot be enabled while the Public URL starts with http:// and is not a loopback address (or, without a public URL, while you are connected over plain HTTP); the page explains why and the server refuses the change with the same message. The sign-in cookies of the flow are always marked Secure.

  1. Create a public client (no client secret is required; a secret is accepted but not needed with PKCE).
  2. Allow the two redirect URIs shown under Settings → Single sign-on → Register at the provider: <PUBLIC_URL>/api/auth/oidc/callback for sign-in and <PUBLIC_URL>/api/auth/oidc/link-callback for linking an existing local account. Each has a copy button.
  3. Allow the post-logout redirect URI from the same list, <PUBLIC_URL>/login, so signing out of BoardGameTracker also ends the session at the provider and comes back to the login page.
  4. Grant the scopes openid profile email. openid is always requested, even when it is missing from the scopes field, because the sign-in relies on the id_token.

PUBLIC_URL (or the Public URL setting) must be the address users type in the browser, e.g. https://games.example.com. When it is not set, the backend derives it from the incoming request, which only works when the reverse proxy forwards the host and scheme (see TRUSTED_PROXIES); the settings page warns when this fallback is in use. Changing the public URL after setup changes the redirect URI, so update the provider registration as well.

Configure the provider in BoardGameTracker

Section titled “Configure the provider in BoardGameTracker”

An administrator fills in the provider under Settings → Single sign-on. Enter the authority (issuer URL) and press Test discovery before saving: it fetches the provider’s discovery document and tells you whether the reported issuer matches what you typed. Most providers only need the button label, authority and client ID; the claim fields are filled in with the standard defaults and the endpoint fields are for providers that do not follow them.

The same configuration is available through the API (POST /api/admin/oidc-providers, also in Swagger when SWAGGER_ENABLED=true):

{
"displayName": "Company login",
"authority": "https://sso.example.com/realms/games",
"clientId": "boardgametracker",
"scopes": "openid profile email",
"autoProvisionUsers": true,
"rolesClaimType": "groups",
"adminGroupValue": "bgt-admins",
"defaultRole": "User"
}
  • authority (the provider’s address, not BoardGameTracker’s) must use https; plain http is accepted only for a provider on a local address, and single sign-on itself still needs HTTPS as described above. It must match the issuer in the provider’s discovery document. Pointing an existing provider at a different authority unlinks every account linked to it, because a user id from one issuer says nothing about the same id at another; users sign in with their password and link the provider again.

  • autoProvisionUsers creates an account with the default role on first sign-in. When the username or the email already belongs to a local account that is not linked, the sign-in is refused and the user is told to log in with the password first and link the provider under Settings → Account → External logins. Accounts are never linked automatically by username or email.

  • The claim fields start with these defaults, and an empty field uses its default as well:

    Field API property Default
    Username claim usernameClaimType preferred_username
    Email claim emailClaimType email
    Display name claim displayNameClaimType name
    Avatar claim pictureClaimType picture
    Groups/roles claim rolesClaimType groups
    Admin group value adminGroupValue bgt-admins
  • rolesClaimType / adminGroupValue / defaultRole map the provider’s groups to a role: Admin when the named group is present in that claim, otherwise the default role (User or Reader). With the defaults, members of a group named bgt-admins in the groups claim become Admin, so create that group at your provider (or set your own group name) and make sure the provider sends the groups claim. For accounts created through the provider this is applied again on every sign-in, so a group change at the provider takes effect at the next sign-in. The last active admin is never demoted; the server logs a warning instead.

  • When the provider sends no display name, the username becomes the display name; when it sends no email, the account gets a placeholder address ending in @sso.external. At every later sign-in an empty display name and the placeholder email are filled in from the provider, but only when no other account uses that email. Values a user or admin set themselves are never overwritten, and local accounts that were linked to the provider keep their own profile. With debug logging enabled, the server logs which claim types (names only, no values) arrived in the id_token and the userinfo response, which helps when a claim seems to be missing.

  • A role that an admin sets under Settings → Account is a local override and is no longer changed by the provider. Access → Let the identity provider manage the role hands it back. Local accounts that were linked to the provider later keep their local role.

  • displayName is the label of the sign-in button and of the linked account under Settings → Account. It can be changed at any time; links are stored per issuer and subject, so linked accounts keep working and the redirect URIs stay the same.

  • Only one provider can be configured; creating a second one is refused. The login page shows one Continue with … button. Switch Enabled off to hide the button without losing the configuration.

  • Removing the provider also removes every linked external account; people keep their local password if they have one.

On every sign-in and link, BoardGameTracker reads the standard OIDC picture claim from the id_token or the userinfo response and uses that picture as the user’s avatar in the menu and the user lists. Set Avatar claim (pictureClaimType) when your provider puts the picture address in another claim. Your identity provider must include the claim, which usually means requesting the profile scope; if the provider sends no picture, users simply keep their initials.

  • The server downloads the picture once, right after the sign-in, and stores it like an uploaded profile picture. Browsers never load it from the provider. The download only accepts https addresses (a provider on localhost may use its own loopback address during development), refuses addresses that resolve to private, link-local or cloud metadata IPs unless they belong to the provider’s own host, follows redirects only on the same host, gives up after 5 seconds, and only accepts images up to 2 MB.
  • A picture the user uploads under Settings → Account always wins; the provider never replaces it. After a user removes their picture, the same provider picture is not imported again; it only comes back when the provider reports a different picture address.
  • The picture is downloaded again only when its address changes. A failed download is logged as a warning, never blocks the sign-in, and is retried at the next sign-in.
  • Some providers only fill the claim when their avatar setting allows it. In Authentik, for example, the picture claim depends on the configured avatar source.

Anyone with a local account can link it to the provider under Settings → Account → External logins. Afterwards both the password and the provider sign in to the same account, and the same screen shows when the link was made and lets the user unlink it again. An account created through the provider has no password, so its last external login cannot be unlinked until a password is set (Forgot password, or an admin reset). A sign-in through the provider is refused when the email already belongs to an unlinked local account, so link first.

The backend exchanges the code itself and validates the returned id_token before it trusts anything in it: the signature against the provider’s published keys (cached, and fetched again when an unknown key id shows up), the issuer from the discovery document, the audience (the client ID, and azp when there are several audiences), the expiry with two minutes of clock skew, and the nonce of this sign-in. Only RS256 and ES256 are accepted. The sub of the id_token identifies the user; the userinfo endpoint is still called for extra claims when the provider has one, and its sub must match. When the provider sends the iss parameter on the callback (RFC 9207), it must be the provider’s issuer.

Disabling users and the break-glass account

Section titled “Disabling users and the break-glass account”

An admin opens Settings → Account → Access next to a user to see the identity provider accounts linked to them, unlink one, or disable the user. A disabled user cannot sign in with a password or through the provider, their refresh tokens are revoked, their provider session is forgotten and access tokens that were already issued stop working right away. You cannot disable yourself or the last active admin.

  • Sign-in errors come back to the app as a short message (provider refused, email already registered, provisioning disabled, …); the details are in the server log.
  • Local accounts keep working, and admins can still create local users (for example Reader accounts for the kids) while a provider is enabled. Keep the local admin password somewhere safe: it is the way in when the provider is down.
  • Removing a user at the provider does not end an existing BoardGameTracker session. Disable or delete the user under Settings → Account as well.
  • Signing out of BoardGameTracker after a provider sign-in also signs out at the provider when its discovery document advertises an end_session_endpoint; the id_token needed for that stays on the server.
  • For development with the Vite dev server set PUBLIC_URL=http://localhost:5443 so both the provider round trip and the app hand-off go through the dev proxy.