Administration

Custom templates

Override the login, register, verify-email, email-verified, select-org, two-factor, device, error and delegated billing pages (subscribe, invoices, billing-usage) per application, and the transactional emails globally, using the TEMPLATES_DIR environment variable.

Auth Service ships built-in templates for its server-rendered auth pages (login, register, verify-email, email-verified, select-org, two-factor, device, error), for the invitation pages (accept-invitation, complete-registration), for the delegated billing pages, and for its transactional emails. You can override any of them by mounting a volume at the directory pointed to by TEMPLATES_DIR: pages globally or per application, emails globally (the application invitation e-mail also per application, see Email templates).

TEMPLATES_DIR is what turns server-rendered pages on. When it is unset, login (for an application), register, verify-email, email-verified, select-org, two-factor, device and error are served by the built-in Vue single-page app instead, and every e-mail uses the built-in template. The invitation and billing pages are always rendered from templates (the built-in ones unless you override them).

Resolution order

For a given page (e.g. login) and application slug (e.g. my-app), the template resolver checks candidates in this order:

  1. <TEMPLATES_DIR>/<appSlug>/login.html — per-application override
  2. <TEMPLATES_DIR>/default/login.html — global override
  3. Built-in template shipped with auth-service — fallback

The built-in templates are baked into the Docker image (/app/templates/default), outside TEMPLATES_DIR. A volume mounted on TEMPLATES_DIR therefore never hides them and does not need a copy of the default/ directory: only put the files you want to override. Each file is resolved independently, so overriding login.html leaves every other page and email on the built-in version.

If a server-rendered page template fails to render (for example a syntax error in your override), the service does not return an error: it serves the Vue SPA instead and logs a warning:[templates] auth page template failed to render; serving the SPA insteadThe log entry carries the page, the appSlug and the err message (the request URL is deliberately not logged). If your pages suddenly look like the default dashboard design, check for this line first.

Template syntax

Templates are rendered with Eta. The it. prefix is mandatory.

TagBehaviour
<%= it.NAME %>Outputs the value HTML-escaped. Use it for text and attribute values.
<%~ it.NAME %>Outputs the value raw. Required for the JSON variables below, and only safe for values the service itself produced.
<% if (cond) { %> … <% } %>Plain JavaScript control flow.
Older versions of this guide showed {{VAR}} and {{#if …}}. That syntax is not supported: such a template is rendered literally, placeholders included. Use the Eta tags above.

Auth page variables

Variables are UPPER_SNAKE_CASE and are strings, except where a boolean is noted.

VariableDescription
it.AUTH_URLThe BETTER_AUTH_URL value (base URL of auth-service)
it.REDIRECT_TOThe redirectTo query parameter forwarded from the OAuth flow
it.OAUTH_QUERYFull OAuth query string (including the BetterAuth signature) needed to resume the authorization flow
it.ERROR_MESSAGEError message from a previous failed attempt (may be empty)
it.ALLOW_REGISTERBoolean — whether new-user registration is permitted
it.SOCIAL_PROVIDERS_JSONJSON array of the social provider slugs to offer, e.g. ["google","github"]. On the login page it is narrowed to the provider the browser last signed in with, when LAST_LOGIN_METHOD_ENABLED provides that hint
it.SOCIAL_PROVIDERS_ALL_JSONJSON array of every social provider the application allows (not narrowed)
it.LOGIN_URLLogin URL with the OAuth query string preserved
it.REGISTER_URLRegister URL with the OAuth query string preserved
it.ORGANIZATIONS_JSONJSON array of the user's organizations (select-org page only)
it.CONFIRMATION_STATUSWhich flow completed — activated, pending, updated, or empty (email-verified page only)
it.CONFIRMATION_HEADING, it.CONFIRMATION_SUBTITLE, it.CONFIRMATION_BADGE, it.CONFIRMATION_BODYCopy for the completed flow, derived from CONFIRMATION_STATUS (email-verified page only)
it.APP_NAME, it.LOGO_URL, it.LOGO_DARK_URLAPP_NAME, APP_LOGO_URL and APP_LOGO_DARK_URL (empty string when unset). Set on the error, billing and invitation pages
it.ACTION_URL, it.APP_SLUGACTION_URL is /api/auth/sign-in/email on the login, register, verify-email, email-verified and two-factor pages, a registration endpoint on the invitation pages, and empty elsewhere; APP_SLUG is the client_id (empty when absent)

Which built-in page reads which variable:

PageVariables used
loginAUTH_URL, REDIRECT_TO, OAUTH_QUERY, ERROR_MESSAGE, ALLOW_REGISTER, SOCIAL_PROVIDERS_JSON, SOCIAL_PROVIDERS_ALL_JSON, REGISTER_URL
registerAUTH_URL, REDIRECT_TO, OAUTH_QUERY, SOCIAL_PROVIDERS_JSON, LOGIN_URL
two-factorAUTH_URL, REDIRECT_TO, OAUTH_QUERY, ERROR_MESSAGE, LOGIN_URL
select-orgAUTH_URL, OAUTH_QUERY, ORGANIZATIONS_JSON
email-verifiedCONFIRMATION_STATUS, CONFIRMATION_HEADING, CONFIRMATION_SUBTITLE, CONFIRMATION_BADGE, CONFIRMATION_BODY, LOGIN_URL
verify-email, deviceAUTH_URL
errorAPP_NAME, LOGO_URL, LOGO_DARK_URL, ERROR_CODE, ERROR_DESCRIPTION, ERROR_HEADING, ERROR_SUBTITLE, ERROR_BADGE, LOGIN_URL
accept-invitation (/accept-invitation)ACTION_URL, ACCEPT_URL, APP_NAME, AUTH_URL, EMAIL, ERROR_MESSAGE, HAS_ACCOUNT, INVITATION_ID, INVITATION_KIND, INVITATION_ROLE, INVITATION_TOKEN, INVITER_NAME, LOGIN_URL, LOGO_URL, LOGO_DARK_URL, SESSION_EMAIL, SESSION_EMAIL_JS, SESSION_MATCHES (boolean), SOCIAL_PROVIDERS_JSON, SOCIAL_URL, TARGET_NAME, TARGET_NAME_JS
complete-registration (/complete-registration)APP_NAME, AUTH_URL, EMAIL, ERROR_MESSAGE, INVITATION_TOKEN, LOGIN_URL, LOGO_URL, LOGO_DARK_URL, SOCIAL_PROVIDERS_JSON, TARGET_NAME
subscribe, invoices, billing-usageLOCALE, APPLICATION_NAME, APP_NAME, LOGO_URL, and the page's own data (see below; invoices also reads APP_SLUG)

Delegated billing pages

An application whose billing mode is delegated gets three pages, served by the service for its signed-in users: /billing/subscribe, /billing/invoices and /billing/usage, each taking the application's slug as ?client_id=. The application advertises the mode in GET /api/app-config (billingMode) and links to these pages from its own UI. The mode is set by an administrator in the application's settings (billingMode, none or delegated), and defaults to none (no pages).

Each page is for the user who is signed in, and only when the user holds an active access to the application. Otherwise:

SituationResponse
Not signed inRedirect to the application's sign-in, then back to the page
Application unknown, inactive, or not delegating billing404 with the Billing is not available here page
Signed in, no access to the application403 with the no access page
A template fails to render500, a static page. The SPA is never served instead, and the log names the page and the application, never the data

Variables of the billing pages (all escaped with <%= %> unless noted):

PageVariables
subscribePLANS (each with name, description, priceLabel, isCurrent, isFree, checkoutAvailable, id), BILLING_PORTAL_AVAILABLE, APPLICATION_ID, APPLICATION_NAME
invoicesINVOICES (each with date, status, total, paid, hostedUrl, pdfUrl), HAS_INVOICES, NEXT_CURSOR (for the older invoices link), APP_SLUG
billing-usageFEATURES (each with featureKey, unit, used, limit (null when unlimited), remaining, percent), HAS_FEATURES, PERIOD_START

Figures are already formatted for the visitor's language (from Accept-Language), and links are only rendered when they are https:// URLs. The subscribe page starts Checkout and the billing portal with a POST from the browser, using the session cookie; the Checkout returns to the Stripe-hosted page, and back to the application.

The copy of the three pages is in English only; dates, amounts and currencies are formatted for the visitor's language. The built-in templates are in templates/default/ and can be overridden per application as usual.

The post-verification page

email-verified is where both verification flows land once the address is confirmed. It is reachable without a session by design: following a verification link proves control of the address, and it no longer signs the user in. Reaching a protected area still requires an explicit sign-in, and the page links to /login for that.

The flow that completed is carried in the page's status query parameter:

?status=Reported by
activatedSign-up: the new account's address is confirmed
pendingChange of address, first hop: the request is confirmed and a link was sent to the new address
updatedChange of address, second hop: the account now uses the new address

An absent or unrecognised value renders the neutral wording. The status is matched against a fixed list before it is used, so the page never echoes the query string.

it.ALLOW_REGISTER is a real boolean. Use it directly (<% if (it.ALLOW_REGISTER) { %>). A template written against the previous string value with it.ALLOW_REGISTER === 'true' now always evaluates to false and silently hides the registration link.

Docker volume mount

services:
  auth-service:
    image: ghcr.io/circle-rd/auth-service:latest
    environment:
      TEMPLATES_DIR: /templates
    volumes:
      - ./my-templates:/templates:ro

Directory structure example (only the files you override are needed; there is no requirement to copy default/ from the image):

my-templates/
├── default/
│   ├── login.html              # global page override for all apps
│   ├── register.html
│   └── emails/
│       └── reset-password.eml  # global email override
└── my-app/
    ├── login.html              # override only for the "my-app" application
    └── emails/
        ├── magic-link.eml          # used when the link was issued for "my-app"
        └── registration-invitation.eml

Minimal login template example

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Sign in</title>
</head>
<body>
  <% if (it.ERROR_MESSAGE) { %>
  <p class="error"><%= it.ERROR_MESSAGE %></p>
  <% } %>

  <form id="login-form">
    <input type="hidden" id="redirect-to" value="<%= it.REDIRECT_TO %>" />
    <input type="hidden" id="oauth-query" value="<%= it.OAUTH_QUERY %>" />
    <label>Email <input type="email" id="email" required /></label>
    <label>Password <input type="password" id="password" required /></label>
    <button type="submit">Sign in</button>
  </form>

  <% if (it.ALLOW_REGISTER) { %>
  <a href="<%= it.REGISTER_URL %>">Create an account</a>
  <% } %>

  <script>
    const authUrl = '<%= it.AUTH_URL %>';
    const socialProviders = <%~ it.SOCIAL_PROVIDERS_JSON %>;
    // POST { email, password } to `${authUrl}/api/auth/sign-in/email`, then
    // resume the OAuth flow with the value of #oauth-query (see the callout below).
  </script>
</body>
</html>

The built-in templates/default/login.html is the reference implementation: copy it and edit it rather than starting from scratch, since it already handles two-factor redirects, social providers and resuming the OAuth flow.

The OAuth query string is required to resume the authorization flow after a successful sign-in. Without it, the user is authenticated but is not redirected back to the client application.

Email templates

Transactional emails (verify-email, reset-password, magic-link, email-otp, change-email, org-invitation, registration-invitation) use the same mechanism with .eml files under an emails/ sub-directory:

  1. <TEMPLATES_DIR>/<appSlug>/emails/<name>.eml — per-application override
  2. <TEMPLATES_DIR>/default/emails/<name>.eml — global override
  3. Built-in template shipped in the image — fallback

An e-mail has an application context in two cases: registration-invitation always has one, and verify-email, reset-password, change-email and magic-link have one when the link was issued for a request whose callbackURL carries a client_id (the sign-in and registration pages of an application do). The slug must be lowercase letters, digits and hyphens; any other value is ignored and the default templates apply. email-otp and org-invitation are sent without an application slug and resolve from step 2 or 3 only.

The HTML body escapes the values you insert with <%= %>; the subject and the plain-text part do not, because they are not HTML.

Email overrides need TEMPLATES_DIR to be set, but are independent of the page templates, and each one is independent of the others.

File format

An .eml file has a frontmatter block with a required subject, an HTML body, and an optional plain-text body after a ===TEXT=== line:

---
subject: Confirm your address for <%= it.appName %>
---
<!DOCTYPE html>
<html>
<body>
  <p>Hello, confirm your address:</p>
  <p><a href="<%= it.url %>">Verify my email</a></p>
</body>
</html>
===TEXT===
Confirm your address: <%= it.url %>

Optional frontmatter keys: from and replyTo (single-line values). When the ===TEXT=== section is omitted, a plain-text version is derived from the HTML.

Email variables

Email variables are camelCase. These are available in every email:

VariableDescription
it.appNameAPP_NAME
it.authUrlBETTER_AUTH_URL
it.appSlugApplication slug (empty string when the e-mail has no application context, see above)
it.supportEmailMAIL_REPLY_TO (empty string when unset)
it.logoUrlAPP_LOGO_URL (empty string when unset)
it.hasLogo, it.logoCidWhether the instance logo is embedded, and its content id (see below)

Plus, per template:

TemplateExtra variables
verify-email, reset-passwordurl, email, expiresInHours
change-emailurl, email, newEmail, expiresInHours
magic-linkurl, email, expiresInMinutes
email-otpotp, email, type (sign-in, email-verification, forget-password or change-email), expiresInMinutes
org-invitationurl, email, inviterName, orgName, role
registration-invitationurl, email, inviterName (may be undefined), expiresInHours

The built-in emails are wrapped in a shared branded layout, with the instance logo embedded as an inline attachment so mail clients never need to load a remote image. An override file is rendered as is and is not wrapped: it must be a complete HTML document. To show the logo in an override, reference the embedded attachment:

<% if (it.hasLogo) { %>
<img src="cid:<%= it.logoCid %>" alt="<%= it.appName %>" width="48" height="48" />
<% } %>

The logo comes from APP_LOGO_URL (a public http(s) URL that must not redirect to a private address, or a data: URI), is limited to 256 KB, and is fetched in the background: the very first message sent right after startup may use a text header until it has been retrieved.

Which page is served

A request with a client_id renders the template of that application, or the default one. A request to /login without client_id is the sign-in of Auth Service itself (administration interface, profile, device approval): it is served by the built-in admin app, with its branding and theme, never by a template.

Registration

Accounts are created through an application with allowRegister enabled, through an invitation flow, or by an administrator. POST /api/auth/sign-up/email is refused (403) unless its callbackURL carries the client_id of an application that allows registration, and a social sign-in creates an account only under the same condition (or for a valid invitation); otherwise it signs in an existing account. The built-in templates do this by sending the authorization URL (/api/auth/oauth2/authorize?client_id=…&sig=…) as callbackURL; a custom template, or an application's own sign-up form, must do the same. Magic-link and email-OTP sign-in (MAGIC_LINK_ENABLED, EMAIL_OTP_ENABLED) only sign in existing accounts.

Copyright © 2026