Custom templates
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:
<TEMPLATES_DIR>/<appSlug>/login.html— per-application override<TEMPLATES_DIR>/default/login.html— global override- 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.
[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.
| Tag | Behaviour |
|---|---|
<%= 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. |
{{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.
| Variable | Description |
|---|---|
it.AUTH_URL | The BETTER_AUTH_URL value (base URL of auth-service) |
it.REDIRECT_TO | The redirectTo query parameter forwarded from the OAuth flow |
it.OAUTH_QUERY | Full OAuth query string (including the BetterAuth signature) needed to resume the authorization flow |
it.ERROR_MESSAGE | Error message from a previous failed attempt (may be empty) |
it.ALLOW_REGISTER | Boolean — whether new-user registration is permitted |
it.SOCIAL_PROVIDERS_JSON | JSON 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_JSON | JSON array of every social provider the application allows (not narrowed) |
it.LOGIN_URL | Login URL with the OAuth query string preserved |
it.REGISTER_URL | Register URL with the OAuth query string preserved |
it.ORGANIZATIONS_JSON | JSON array of the user's organizations (select-org page only) |
it.CONFIRMATION_STATUS | Which flow completed — activated, pending, updated, or empty (email-verified page only) |
it.CONFIRMATION_HEADING, it.CONFIRMATION_SUBTITLE, it.CONFIRMATION_BADGE, it.CONFIRMATION_BODY | Copy for the completed flow, derived from CONFIRMATION_STATUS (email-verified page only) |
it.APP_NAME, it.LOGO_URL, it.LOGO_DARK_URL | APP_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_SLUG | ACTION_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:
| Page | Variables used |
|---|---|
login | AUTH_URL, REDIRECT_TO, OAUTH_QUERY, ERROR_MESSAGE, ALLOW_REGISTER, SOCIAL_PROVIDERS_JSON, SOCIAL_PROVIDERS_ALL_JSON, REGISTER_URL |
register | AUTH_URL, REDIRECT_TO, OAUTH_QUERY, SOCIAL_PROVIDERS_JSON, LOGIN_URL |
two-factor | AUTH_URL, REDIRECT_TO, OAUTH_QUERY, ERROR_MESSAGE, LOGIN_URL |
select-org | AUTH_URL, OAUTH_QUERY, ORGANIZATIONS_JSON |
email-verified | CONFIRMATION_STATUS, CONFIRMATION_HEADING, CONFIRMATION_SUBTITLE, CONFIRMATION_BADGE, CONFIRMATION_BODY, LOGIN_URL |
verify-email, device | AUTH_URL |
error | APP_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-usage | LOCALE, 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:
| Situation | Response |
|---|---|
| Not signed in | Redirect to the application's sign-in, then back to the page |
| Application unknown, inactive, or not delegating billing | 404 with the Billing is not available here page |
| Signed in, no access to the application | 403 with the no access page |
| A template fails to render | 500, 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):
| Page | Variables |
|---|---|
subscribe | PLANS (each with name, description, priceLabel, isCurrent, isFree, checkoutAvailable, id), BILLING_PORTAL_AVAILABLE, APPLICATION_ID, APPLICATION_NAME |
invoices | INVOICES (each with date, status, total, paid, hostedUrl, pdfUrl), HAS_INVOICES, NEXT_CURSOR (for the older invoices link), APP_SLUG |
billing-usage | FEATURES (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 |
|---|---|
activated | Sign-up: the new account's address is confirmed |
pending | Change of address, first hop: the request is confirmed and a link was sent to the new address |
updated | Change 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.
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:
<TEMPLATES_DIR>/<appSlug>/emails/<name>.eml— per-application override<TEMPLATES_DIR>/default/emails/<name>.eml— global override- 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:
| Variable | Description |
|---|---|
it.appName | APP_NAME |
it.authUrl | BETTER_AUTH_URL |
it.appSlug | Application slug (empty string when the e-mail has no application context, see above) |
it.supportEmail | MAIL_REPLY_TO (empty string when unset) |
it.logoUrl | APP_LOGO_URL (empty string when unset) |
it.hasLogo, it.logoCid | Whether the instance logo is embedded, and its content id (see below) |
Plus, per template:
| Template | Extra variables |
|---|---|
verify-email, reset-password | url, email, expiresInHours |
change-email | url, email, newEmail, expiresInHours |
magic-link | url, email, expiresInMinutes |
email-otp | otp, email, type (sign-in, email-verification, forget-password or change-email), expiresInMinutes |
org-invitation | url, email, inviterName, orgName, role |
registration-invitation | url, email, inviterName (may be undefined), expiresInHours |
Layout and logo
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.