Security — HTMQL Documentation

Security

Authentication determines who can log in. Authorization determines which pages they can access once logged in.

Authentication

The server supports four authentication modes, configured via auth.type. This setting is required - the server will not start if it is not specified.

  • oidc - Single sign-on via an external OIDC provider (Microsoft Entra, Keycloak, Auth0, and others).
  • sql - Built-in form-based login validated against the SQL database engine's own logins.
  • table - Built-in form-based login validated against a users table with bcrypt password hashes. Works with every database type, including SQLite.
  • none - No authentication. All pages are publicly accessible.

OIDC Authentication

OIDC (OpenID Connect) is a widely supported standard for single sign-on. When enabled, unauthenticated users are redirected to the OIDC provider to log in, then returned to the application with an access token.

To use OIDC, set auth.type=oidc and configure the auth.oidc section. The exact values depend on your OIDC provider - refer to the included sample configuration files for common starting points.

auth.oidc settings:

  • auth.oidc.issuerUrl - the base URL of the OIDC provider (e.g. https://login.microsoftonline.com/{tenant}/v2.0)
  • auth.oidc.clientId - the client ID registered with the OIDC provider
  • auth.oidc.clientSecret - the client secret registered with the OIDC provider
  • auth.oidc.redirectUrl - the URL the OIDC provider redirects back to after login (must match the registered redirect URI)
  • auth.oidc.scopes - scopes to request from the provider (default: ["openid","profile","email"])
  • auth.oidc.rolesClaim - comma-separated list of token claims to use for role assignment (default: "roles")
  • auth.oidc.rolesClaimRequired - if true, login fails when no roles claim is present in the token (default: false)
  • auth.oidc.localRoles - if true, roles are looked up from a local SQL table instead of the OIDC token claim (default: false)
  • auth.oidc.localRoleLookupClaim - a comma-separated list of token claims, tried in order, used to derive the user's identity (the local-roles lookup key and @auth_username) (default: "oid, sub, preferred_username, upn, email"). Lead with a claim your IdP guarantees stable, unique, and not user-editable (e.g. oid, sub); only fall through to preferred_username/email when the IdP verifies and locks them, since user-editable claims would let one user impersonate another's roles or row ownership.
  • auth.oidc.displayNameClaim - token claim used as the user's display name (default: "name")

When localRoles is enabled, configure the auth.local settings as described in Role Assignment.

Setting Up OIDC with Microsoft 365 (Entra ID)

This walkthrough registers an application in Microsoft Entra ID (formerly Azure AD) and wires it to HTMQL. You need an account with permission to register applications in the tenant - typically a Global Administrator, Application Administrator, or Cloud Application Administrator. Do this in the Microsoft Entra admin center (or the App registrations blade of the Azure portal).

1. Register the application

  1. Sign in at https://entra.microsoft.com.
  2. Go to Identity → Applications → App registrations, then click New registration.
  3. Name - anything recognizable, e.g. HTMQL - My App. The user never sees this unless they are on the consent screen.
  4. Supported account types - choose Accounts in this organizational directory only (single tenant) unless you specifically need to admit guests or other tenants.
  5. Redirect URI - set the platform to Web and enter the callback URL. This must exactly match auth.oidc.redirectUrl in config.json. HTMQL serves the callback at the fixed path /auth/oidc/callback, so:
    • Production: https://yourapp.example.com/auth/oidc/callback
    • Local testing: http://localhost:8080/auth/oidc/callback
  6. Click Register.

You may add more redirect URIs later under Authentication → Web → Redirect URIs (e.g. to support both a localhost dev URL and a production URL). Every URL the server might redirect back to must be listed here.

2. Collect the IDs (for issuerUrl and clientId)

On the app's Overview page, copy:

  • Application (client) ID → this is auth.oidc.clientId.
  • Directory (tenant) ID → use it to build auth.oidc.issuerUrl: https://login.microsoftonline.com/<tenant-id>/v2.0

3. Create a client secret (for clientSecret)

  1. Go to Certificates & secrets → Client secrets → New client secret.
  2. Add a description and an expiry (Entra caps secrets at 24 months; pick a reminder date - when it expires, logins break until you create a new one).
  3. Click Add, then immediately copy the secret Value (not the Secret ID). The value is shown only once and cannot be retrieved later.
  4. Put it in auth.oidc.clientSecret. Prefer an environment variable rather than committing it: in config.json use "clientSecret": "${OIDC_CLIENT_SECRET}" and set the env var on the server.

Certificate credentials are also supported by Entra, but HTMQL uses the client-secret flow - use a secret.

4. Configure tokens and claims

By default Entra issues openid, profile, and email, which cover the standard name, preferred_username, email, oid, and sub claims HTMQL uses to identify the user. No extra configuration is needed for basic login.

With the default localRoleLookupClaim ("oid, sub, preferred_username, upn, email"), the user's identity — the value of @auth_username and the local-roles lookup key — is Entra's oid claim, an immutable GUID like a1b2c3d4-.... That is the safest choice, but GUIDs are awkward to key a roles table by or to display in pages. On a standard Entra tenant, preferred_username is the user's UPN (e.g. alice@contoso.com) and is controlled by the tenant admin, not the user, so it is safe to prefer it explicitly:

"localRoleLookupClaim": "preferred_username, upn, email, sub"

Only do this when the IdP controls the claim; leave the immutable-first default for IdPs where users can edit their own username or email.

If you set auth.oidc.displayNameClaim or localRoleLookupClaim to a claim that is not present by default, add it under Token configuration → Add optional claim (ID token).

5. Decide how roles are assigned

HTMQL can get roles two ways - pick one:

  • App roles from the token (rolesClaim, the default "roles" claim): define roles on the app registration and assign users/groups to them.
    1. Go to App roles → Create app role. Set Display name, an Allowed member type (Users/Groups), a Value (this string is what HTMQL matches against +role decorators, e.g. admin), then Apply.
    2. In the tenant's Enterprise applications list, open the same app → Users and groups → Add user/group, and assign people to each role.
    3. In config.json keep auth.oidc.rolesClaim at "roles". Set rolesClaimRequired: true if a user with no assigned role should be rejected at login.
  • Local roles from a SQL table (localRoles: true): leave the app registration's roles empty and instead store role assignments in your database. See Role Assignment. HTMQL matches the logged-in user against the table using localRoleLookupClaim (default tries oid, sub, preferred_username, upn, email). Whichever claim wins is the value your roles-table username column must contain — under the default that is the oid GUID; set localRoleLookupClaim to prefer preferred_username (see step 4) if you want to key the table by UPN instead.

6. Grant API permissions (only if using Microsoft 365 email)

Basic sign-in needs no special API permissions beyond the delegated openid/profile/email (User.Read is added by default). You only need to add Graph permissions if the app uses the email alias in m365_draft or m365_send mode:

  1. Go to API permissions → Add a permission → Microsoft Graph → Delegated permissions.
  2. Add Mail.ReadWrite (for m365_draft) and/or Mail.Send (for m365_send).
  3. If your tenant requires admin consent, click Grant admin consent for <tenant>.
  4. Add the matching scope to auth.oidc.scopes in config.json (e.g. add "Mail.Send"). Users must log in again after a scope is added - existing sessions don't pick up new scopes. See Email Alias for details.

7. Fill in config.json

Use config-mssql-oidc.json or config-sqlite-oidc.json as a starting point. The OIDC section ends up looking like:

{
  "auth": {
    "type": "oidc",
    "oidc": {
      "issuerUrl":    "https://login.microsoftonline.com/<tenant-id>/v2.0",
      "clientId":     "<application-client-id>",
      "clientSecret": "${OIDC_CLIENT_SECRET}",
      "redirectUrl":  "https://yourapp.example.com/auth/oidc/callback",
      "scopes":       ["openid", "profile", "email"],
      "rolesClaim":   "roles"
    }
  }
}

8. Test the login

  1. Start the server and browse to any non-public page. You should be redirected to the Microsoft sign-in page.
  2. Sign in. On first use you (or an admin) may see a consent prompt listing the requested permissions - approve it.
  3. You should land back on the app, authenticated. Confirm username / displayname render as expected and that role decorators behave correctly.

Common pitfalls

  • AADSTS50011 redirect mismatch - the redirectUrl in config.json is not listed verbatim under the app's Authentication → Redirect URIs. Scheme, host, port, and path must match exactly (http vs https, trailing slash, localhost vs 127.0.0.1 all matter).
  • Login works but no roles - you assigned users in Enterprise applications → Users and groups but didn't define the App roles, or rolesClaim doesn't match. Verify with the token, or switch to localRoles.
  • Logins suddenly fail - the client secret expired. Create a new one and update clientSecret.
  • Email alias 403 - the Graph permission wasn't admin-consented, or the scope wasn't added to auth.oidc.scopes and the user hasn't logged in again since.

Built-In SQL Authentication

When enabled, unauthenticated users are presented with a login form. The submitted credentials are validated directly against the SQL database's authentication system. If accepted, a session cookie is set in the user's browser.

Note: the database validates the login, but all subsequent database queries run under the application's own connection - not the user's credentials. SQLite does not support SQL authentication - use Table Authentication instead.

To use SQL authentication, set auth.type=sql and configure role assignment under auth.local as described in Role Assignment.

Custom Login Page

The default login form is plain and unbranded. To replace it, add a login.html file to the system folder. When present, it is rendered in place of the built-in form (wrapped by header.html / footer.html, just like a normal page). If the file is absent, the server falls back to the built-in form - the same way a missing error.html or 404.html falls back to a built-in default. The file is optional, so existing apps need no changes.

The template receives two fields:

Field Contents
.Err An error message to display (e.g. "Invalid username or password"), or empty on first load
.ReturnTo The path to redirect to after a successful login; submit it back as a hidden field

The form must POST to /login with username and password fields. A minimal system/login.html:

<h1>Sign in</h1>
{{if .Err}}<div class="error">{{.Err}}</div>{{end}}
<form method="post" action="/login">
  <input type="hidden" name="return_to" value="{{.ReturnTo}}">
  <label>Username or email</label>
  <input name="username" autofocus>
  <label>Password</label>
  <input type="password" name="password">
  <button type="submit">Login</button>
</form>

Table Authentication

Set auth.type=table to validate the login form against a users table in the application database instead of the database engine's login system. This is the only built-in login option for SQLite, and works identically on MSSQL and Postgres. Passwords are stored as bcrypt hashes; the server compares them in Go, so the password is never bound into a SQL statement and never appears in query logs.

Configure the users table under auth.local (shared with Role Assignment):

"auth": {
  "type": "table",
  "adminRole": "admin",
  "local": {
    "table": "users",
    "username": "username",
    "password": "password_hash",
    "role": "roles",
    "displayName": "display_name",
    "enabled": "enabled"
  }
}
  • auth.local.table, auth.local.username, auth.local.password - required; the server will not start without them.
  • auth.local.password - column holding the bcrypt hash. NULL/empty means the user cannot log in.
  • auth.local.enabled - optional integer column (1 = active, 0 = disabled). A disabled user is refused at login. Omit the setting to skip the check.
  • auth.local.role / auth.local.displayName - as in Role Assignment. With table auth, keep one row per user and store multiple roles as a comma-separated list in that row.

Usernames are matched case-insensitively and must be unique - if two rows match a username, login is refused until the duplicate is resolved.

Generating password hashes

All hashes are bcrypt, minted either by the server itself (management routes below) or by the CLI helper:

.\htmql-server.exe -hash-password

It prompts twice with echo off (or reads one line from piped stdin for scripting) and prints the hash to stdout. The password is never accepted as a command-line argument. The same password hashes to a different string each time (bcrypt embeds a random salt) - that is normal, and any of them verifies.

Use it to seed the first admin user in a migration. Never commit a hash of a shared or documented password - generate your own:

-- 001_create_users.sql
CREATE TABLE IF NOT EXISTS users (
    username      TEXT NOT NULL,
    password_hash TEXT,
    display_name  TEXT,
    roles         TEXT,
    enabled       INTEGER NOT NULL DEFAULT 1
);

INSERT INTO users (username, password_hash, display_name, roles, enabled)
SELECT 'admin', '<PASTE OUTPUT OF htmql-server.exe -hash-password HERE>', 'Administrator', 'admin', 1
WHERE NOT EXISTS (SELECT 1 FROM users);

Treat the bootstrap password as temporary: change it on the change-password page after first login (which also retires the in-repo hash). The same flag supports direct-SQL account fixes (e.g. an emergency admin password reset with a plain UPDATE).

On Postgres you can alternatively mint hashes in SQL with the pgcrypto extension (crypt('pw', gen_salt('bf', 12))), but be aware the plaintext then appears in the SQL text - visible to log_statement, pg_stat_statements, and console history. The CLI flag avoids that on every database type.

User management routes and pages

Table auth ships with built-in management endpoints (they respond 404 under every other auth.type):

Route Method Who Purpose
/auth/local/change-password GET/POST any logged-in user Change own password; requires the current password. Other sessions for the user are logged out.
/auth/local/users GET auth.adminRole holders List users and render the management forms.
/auth/local/new POST auth.adminRole holders Create a user (username, password, display name, roles, enabled).
/auth/local/update POST auth.adminRole holders Update display name/roles/enabled; optionally reset the password. A reset or disable logs the user out everywhere.
/auth/local/delete POST auth.adminRole holders Delete a user (self-delete is refused) and end their sessions.

The admin routes require auth.adminRole to be configured; with it unset they are disabled entirely. All outcomes are written to the security log as benign messages ("user X created by Y") - never credential material. Login and change-password POSTs share the rateLimit.login limiter; with table auth this limiter is the only brute-force protection (there is no database engine behind it), so leave it enabled.

Like the login form, the two pages can be re-branded by dropping change-password.html and/or manage-users.html into the system folder; the built-in plain forms are the fallback. The change-password template receives .Err/.Msg; the manage-users template receives .Users (list of Username/DisplayName/Roles/Enabled), .Msg, .Err, and .HasDisplay/.HasRoles/.HasEnabled flags for the configured optional columns. Forms post the field names shown in the built-in templates (current_password/new_password/confirm_password; username/password/display_name/roles/enabled).

Passwords are limited to 72 bytes (a bcrypt limit; longer input is rejected rather than silently truncated).

No Authentication

Set auth.type=none to explicitly disable authentication. All pages are publicly accessible to anyone who can reach the server, regardless of any role decorators on individual pages.

Service-credential authentication

Non-session callers - command-line tools, scheduled/background jobs, and helper utilities that render or fetch pages on the server's behalf - authenticate on the normal page routes with a configured service credential. There is no special route and no authorization bypass: a service credential resolves to an ordinary authenticated principal with a fixed set of roles, and each page's normal authorization rules then apply exactly as they would for a user.

Configure service accounts under auth.serviceAccounts:

"auth": {
  "type": "oidc",
  "serviceAccounts": [
    { "name": "po-generate", "secret": "${PO_GENERATE_SECRET}", "roles": ["porenderer"] }
  ]
}
Field Meaning
name Identifier used in logs and as the principal's username (readable in SQL as @auth_username). Never the secret.
secret The shared secret the caller presents. Supports ${VAR} expansion; compared in constant time; never logged.
roles Roles granted to the request (readable as @auth_roles). Page policy is evaluated against these.
allowRemote false (default) accepts the credential only from loopback. Set true to allow off-host use. Behind a reverse proxy on the same host, set server.trustedProxies — locality is then judged by the real client IP resolved through the proxy, not the proxy's own loopback address. Without trustedProxies, every request forwarded by a same-host proxy would look local, so treat the secret as the primary control.

A caller presents the secret in the X-HTMQL-Service-Token request header. On a match the request is authenticated as name with the configured roles; a wrong, missing, or (by default) non-loopback credential is simply treated as unauthenticated. Because the roles are real, a service account is denied any page its roles do not allow - just like a user. Grant each account the least roles it needs, and give render-callback tools a dedicated role (e.g. porenderer) so a page can recognise the callback and skip relaunching the tool - see Breaking a tool→server→tool render loop.

Authorization

Authorization controls which pages an authenticated (or unauthenticated) user can access, based on roles.

Role decorators in the page header control access per page:

  • +rolename - allow users with this role
  • -rolename - deny users with this role
  • +public - allow unauthenticated access (everyone, authenticated or not)
  • +auth / -auth - the reserved auth pseudo-role matches any authenticated user. Use +auth to open a page to all logged-in users even when auth.defaultAllow is false. -auth denies all authenticated users; combine with +public (+public and -auth) to allow anonymous visitors while blocking logged-in users.

Multiple roles can be listed on one line (+admin,editor) or across multiple lines.

Syntax: the +/- sign must be immediately followed by the role name with no space - +admin and -guest are valid; + admin and - guest are a parse error. A role name must be a valid identifier (a letter followed by letters, digits, or underscores). A line that begins with whitespace is treated as a continuation of the previous command, not a role rule, so never indent a +/- decorator.

Method-scoped authorization. A bare decorator applies to every HTTP method. Prefix a decorator with a method to scope it to that method only:

+auth                  ← base rule: any authenticated user may view (GET)
POST:+approver         ← only approver may POST
PUT:+approver,manager  ← approver OR manager may PUT
DELETE:+approver       ← only approver may DELETE

This provides server-side enforcement for PUT/POST/PATCH/DELETE handlers, independent of whatever you conditionally render on the GET. A hand-crafted POST from a user lacking the role is rejected before any SQL runs.

As with the base rules, a method decorator carries one sign followed by a comma-separated list of roles - PUT:+approver,manager allows either role. To combine allow and deny on the same method, use separate lines (POST:+approver and POST:-suspended); they accumulate. Mixing signs in one list (POST:+approver,-suspended) is a parse error.

When a method has its own decorator, its allow list replaces the base allow list for that method, while deny rules accumulate (a base -banned still applies to every method). A method with no decorator inherits the base rule.

Global defaults in the configuration file:

  • auth.defaultAllow - whether pages with no role decorators are accessible by default (true) or denied by default (false)
  • auth.denyPriority - when a user matches both an allow and a deny role, deny wins when true; allow wins when false (default: true)
  • auth.adminRole - optional role name that overrides all page authorization. A user holding this role is allowed onto every page regardless of allow/deny decorators (including an explicit -adminRole), and satisfies the auth()/notauth() template functions. Leave empty (default) to disable.

The authenticated username is available in SQL commands as @auth_username and in templates via the username function. For an anonymous request, @auth_username is an empty string (fail closed: owner = @auth_username can never match a real row, and no real login can collide with a sentinel value), while the username template function returns the display sentinel "ANONYMOUS".

The session also exposes these SQL parameters for authorization:

  • @auth_roles - the caller's roles as a comma-wrapped, lowercased list: ,admin,editor, (leading and trailing commas always present; , when the user has no roles). Test membership unambiguously on any database with @auth_roles LIKE '%,admin,%'.
  • @auth_authenticated - 1 for a logged-in user, 0 for an anonymous request.

All four session parameters (@auth_username, @auth_displayname, @auth_roles, @auth_authenticated) are injected from the session after the request's form, query, and slug values, so a submitted field of the same name can never override them. They are the trustworthy basis for the row-level checks described in Row-Level Security.

Note: an authenticated user with no roles has @auth_roles = ',' — the same value as an anonymous request. When a predicate must distinguish "logged in" from "anonymous", gate on @auth_authenticated = '1' (or reach the query only behind a +role / guard: rule that already requires authentication) rather than inspecting @auth_roles alone.

Role Assignment

Role assignment tells the server which roles a logged-in user has.

With OIDC, roles can come from the token claim (see auth.oidc.rolesClaim) or from a local SQL table (see auth.oidc.localRoles). With SQL and table authentication, roles must always come from a local SQL table (for table auth it is the same users table that holds the password hashes).

Configure the local roles table with these three required settings:

  • auth.local.table - the table that stores role assignments
  • auth.local.username - the column used to match the logged-in user
  • auth.local.role - the column that holds the role name

The table must exist before the server starts. Role assignments can be stored as a comma-separated string in a single row, or as one row per role - both formats are supported.

Optionally, set auth.local.displayName to a column containing the user's display name. If not set, the username is used as the display name. The display name is available in SQL commands as @auth_displayname and in templates via the displayname function. When unauthenticated, @auth_displayname is an empty string and the displayname template function returns "ANONYMOUS".

Templates can check roles using {{role "rolename"}} (returns true if the user literally has the role) and {{notrole "rolename"}} (returns true if they do not). A full list of the user's roles is intentionally not available in templates.

For gating UI to match server-side authorization, use {{auth "rolename"}} / {{notauth "rolename"}}. Unlike role, auth mirrors the page-policy allow semantics: it returns true if the user holds the role or holds the configured auth.adminRole; the reserved public always passes and auth passes for any authenticated user. It accepts multiple roles (OR), as separate arguments or comma-separated: {{auth "approver" "manager"}} or {{auth "approver,manager"}}. Rendering a control with {{if auth "approver"}} shows it to exactly the users a POST:+approver rule will accept.

Row-Level Security (guard alias)

Page authorization (+role rules) controls who may open a page. It does not stop an authorized user from changing a URL slug or form value to reach another user's record - e.g. editing /invoice/123 to /invoice/124. Preventing that object-level access is the page author's responsibility, and the guard special alias is the fail-closed tool for it.

A guard: line runs before every method (GET and all writes). If its query returns at least one row the request proceeds; if it returns no row the request is denied - the server shows the "Access Denied" page, the page's data and write aliases never run, and any open transaction is rolled back. The query value is irrelevant (SELECT 1 is idiomatic): only the presence of a row matters. guard is implicitly critical and requires-records, so you do not add the !/!! modifiers.

{{/*
+auth
@id=0
-- The caller must own this invoice, or hold the admin role.
guard:SELECT 1 FROM invoice
      WHERE id=@id AND (owner=@auth_username OR @auth_roles LIKE '%,admin,%');

GET:$invoice:SELECT * FROM invoice WHERE id=@id;
POST:!!update:UPDATE invoice SET notes=@notes WHERE id=@id;
POST:redirect:SELECT '/invoices' AS [redirect];
*/}}
<title>{{.invoice.number}}</title>

On a list page there is no single record to guard; instead scope the list query itself with the same predicate so the result set only contains rows the caller may see:

GET:invoices:SELECT * FROM invoice
    WHERE owner=@auth_username OR @auth_roles LIKE '%,admin,%'
    ORDER BY created DESC;

Guidelines:

  • Use the same predicate for the list filter and the detail-page guard, so visibility and access stay consistent.
  • Always derive ownership from the session parameters @auth_username / @auth_roles (which cannot be spoofed), never from a user-supplied form field or query parameter.
  • @auth_roles is comma-wrapped, so matching a literal role with LIKE '%,role,%' is identical on every database. To compare against a column holding a role name, concatenate per database: SQLite/PostgreSQL @auth_roles LIKE '%,' || role_column || ',%'; SQL Server @auth_roles LIKE '%,' + role_column + ',%'.
  • A guard whose query references a missing parameter, or that errors, also denies the request (fail closed). The underlying reason is written to the server log (and shown in the browser only in development mode).

Session Management

After login, a session cookie containing only a unique session identifier is sent to the user's browser. All session data is held in the server's memory - if the server restarts, all sessions are lost and users must log in again.

Invalid or expired sessions redirect the user to the login page. Sessions expire after 9 hours by default; configure this with server.sessionMinutes. Set server.idleMinutes to also log out sessions after a period of inactivity (the timer resets on each request and never extends past the absolute lifetime).

The session cookie is HttpOnly, SameSite=Lax, and Secure whenever the server runs over HTTPS. Under HTTPS the cookie name also carries the __Host- prefix, which instructs the browser to enforce Secure + Path=/ + no Domain so the cookie cannot be scoped to a parent domain or sent over plaintext. Over plain HTTP (e.g. local development) the prefix is dropped, since browsers reject __Host- cookies that are not Secure.

Soft IP binding (optional). Set auth.sessionIPBinding.enabled to true to bind each session to the network block of the IP it was created from. If a later request arrives from a different block (compared by coarse prefix - /24 for IPv4, /48 for IPv6 by default), the session is dropped and the user is sent back to login to re-authenticate. The comparison is deliberately coarse so normal roaming within a home or mobile network does not trip it, and it fails open (skips the check) when an IP cannot be resolved, so a misconfiguration never locks users out. Because it relies on the client IP, set server.trustedProxies whenever the server runs behind a reverse proxy - otherwise every request appears to come from the proxy and binding has no effect.

HTTPS Encryption

HTTPS is activated automatically when both a certificate and private key file are present. Generate them using a tool such as Let's Encrypt or OpenSSL, then set their paths in the configuration:

  • server.certFile - path to the TLS certificate file (relative to paths.root)
  • server.keyFile - path to the TLS private key file (relative to paths.root)

When HTTPS is active and no server.port is specified, the server defaults to port 443 instead of 80.

Automatic certificates (ACME / Let's Encrypt)

Instead of managing certFile/keyFile by hand, the server can obtain and auto-renew a publicly-trusted certificate over ACME (Let's Encrypt by default) using autocert. This is the recommended option for a host exposed directly to the public internet — HTTPS "just works" with no manual rotation and no expired-cert outages.

"server": {
  "tls": {
    "acme": {
      "enabled": true,
      "domains": ["app.example.com"],
      "email": "ops@example.com",
      "cacheDir": "certs",
      "acceptTOS": true,
      "directoryURL": ""
    }
  }
}
Setting Default Description
server.tls.acme.enabled false Turn on automatic certificate management. Mutually exclusive with server.certFile/server.keyFile — setting both is a startup error.
server.tls.acme.domains [] Public hostnames the certificate is issued for. At least one is required, and each must resolve publicly to this server before issuance can succeed.
server.tls.acme.email "" Optional contact address registered with the ACME account (expiry notices).
server.tls.acme.cacheDir "certs" Directory (relative to paths.root unless absolute) where issued certificates and keys are cached. Persist this across restarts — Let's Encrypt enforces issuance rate limits, so re-issuing on every restart will quickly exhaust them.
server.tls.acme.acceptTOS false Must be true; agrees to the CA's Terms of Service, which ACME requires. The server refuses to start with ACME enabled and this false.
server.tls.acme.directoryURL "" ACME directory endpoint. Blank uses Let's Encrypt production. Point it at the Let's Encrypt staging URL (https://acme-staging-v02.api.letsencrypt.org/directory) while testing so you don't burn the production rate limit; staging certs are untrusted but prove the flow end-to-end.

Notes:

  • Challenge type is TLS-ALPN-01, answered on the same :443 listener during the TLS handshake — no port 80 is needed. When ACME is enabled the server serves HTTPS (Secure/__Host- cookie and HSTS on), and moves off port 80 to 443 automatically if the port was left at the default.
  • The ACME hostname must resolve publicly to this server before first issuance.
  • If a CDN/edge later terminates TLS in front of the origin, edge TLS can replace this; keeping ACME for the origin (or switching to an edge-issued origin cert) is not wasted either way.

Security Response Headers

Every response carries a baseline set of security headers, set automatically (no configuration):

  • X-Content-Type-Options: nosniff - stops browsers from MIME-sniffing a response into a different content type.
  • X-Frame-Options: SAMEORIGIN - blocks the site from being framed by other origins (clickjacking defense).
  • Referrer-Policy: strict-origin-when-cross-origin - keeps full page URLs (slugs, query parameters) from leaking to other sites via the Referer header; same-origin navigation still sends the full URL.
  • Strict-Transport-Security: max-age=31536000 - sent only when HTTPS is active, instructing browsers to use HTTPS for this host for a year.

A Permissions-Policy header is opt-in via server.permissionsPolicy, because pages may legitimately use browser features such as the camera, microphone, or geolocation. If your pages use none of them, lock the features down:

"server": { "permissionsPolicy": "camera=(), microphone=(), geolocation=()" }

The configured string is sent verbatim as the header value, so any Permissions-Policy directives may be used. Leave it unset (the default) to send no header.

A Content-Security-Policy is intentionally not set by default, because the bundled Hyperscript (_="...") and inline event handlers require inline execution; add one tailored to your pages via a reverse proxy if you need it.

Reverse Proxying

It is possible to run HTMQL behind a reverse proxy or load balancer (IIS, nginx, Apache, Caddy, Cloudflare) that terminates TLS and forwards requests to the server. In that arrangement every request reaches HTMQL from the proxy's address, so by default - which trusts only the direct connection - the real client IP is lost and rate limiting and session IP binding would treat every user as the same client.

Set server.trustedProxies to the proxy's address(es) - CIDR ranges or bare IPs - to fix this. When the direct connection comes from a trusted proxy, the server reads the originating client IP from the X-Forwarded-For header; connections from any other source keep using their direct address. Because the header is honoured only from trusted sources, a client cannot spoof its IP by sending its own X-Forwarded-For.

Configure it whenever the server is not directly exposed to clients. With it set correctly:

  • The IP and login rate limiters count each real client separately, instead of throttling everyone behind the proxy as a single source.
  • auth.sessionIPBinding (if enabled) compares the real client IP rather than the constant proxy IP.
  • Request logs record the real client address.

Leave it empty (the default) only when clients connect to the server directly with no proxy in between. As a safety net, the server logs a one-time SECURITY warning if it receives a request carrying X-Forwarded-For while server.trustedProxies is empty - a strong sign it is running behind a proxy without this setting.

Rate Limiting

The server includes a sliding-window rate limiter that can block abusive or looping request patterns before they reach page processing. The login limiter and the unauthenticated limiter are enabled by default (brute-force and anonymous-flood protection out of the box); the broader IP and duplicate-request limiters are disabled by default and must be explicitly enabled - turn them on for any public-facing deployment.

When a request is blocked, the server returns 429 Too Many Requests and logs a WARNING (IP / duplicate-request limiters) or SECURITY (login / unauthenticated / authenticated limiters) entry. If the request was made by HTMX (i.e. the HX-Request: true header is present) and rateLimit.htmxErrorPath is configured, an HX-Redirect header is also included so the browser navigates to the specified error page.

IP Rate Limiter (rateLimit.ip)

Tracks the total number of requests from each IP address within a sliding time window. Requests from an IP that exceeds maxRequests within windowSeconds are blocked for blockDurationSeconds.

This is a broad safeguard against general hammering from a single source, regardless of which URLs are being requested.

Split Limiters: Unauthenticated & Authenticated (rateLimit.unauthenticated, rateLimit.authenticated)

These two limiters split traffic by authentication state, resolving the tension a single per-IP limiter faces: a shared office NAT can put 50+ legitimate users behind one public IP, so a generic per-IP limit either sits uselessly high or false-positives the morning login rush.

  • Unauthenticated limiter (rateLimit.unauthenticated, on by default) counts only requests that arrive with no valid session, keyed by client IP. This is where abusive anonymous volume lives - scanners, scrapers, and floods - and a short block on an anonymous IP costs nothing. Static assets (/asset, /lib, /favicon.ico) are served on separate routes and are never counted, so the burst of CSS/JS/icon fetches during a page load does not accumulate. For an OIDC app each user makes only ~2-3 unauthenticated requests (a protected-page hit that redirects to the identity provider, then the callback) before they hold a session, so the 600 / 60s default clears even a few-hundred-user NAT with headroom. Validate against real logging.http output from your busiest egress IP and tune before relying on it.

  • Authenticated limiter (rateLimit.authenticated, off by default) counts requests that carry a valid session, keyed on the session identity (username) rather than the IP. Because the key is the identity, it is NAT-immune: every user behind a shared IP is limited independently, so one user hammering never throttles the others. It is off by default only because authenticated abuse is rare and lower-priority; it is safe to enable.

A logged-in user is therefore never counted by the unauthenticated limiter, and vice versa. For a fully gated app (like an OIDC deployment where every page requires authentication), the unauthenticated limiter is the primary public-exposure flood guard. Note that for a genuinely public app (auth.type none, where legitimate users are all unauthenticated) the unauthenticated limiter reintroduces the shared-NAT problem - size it generously or leave it to an upstream edge.

Duplicate Request Limiter (rateLimit.duplicateRequest)

Tracks requests per IP address and URL (path + query string) combination. Only trips when the same request is repeated rapidly from the same IP - normal browsing across different pages does not accumulate toward this limit.

This is specifically suited to detecting execute callback loops, where an external program called by a page makes an HTTP request back to the same URL it was triggered from, causing it to run again indefinitely. A tighter maxRequests value and longer blockDurationSeconds are recommended compared to the IP limiter.

Login Rate Limiter (rateLimit.login)

Applies exclusively to the /login endpoint, in addition to the general IP limiter. Tracks login attempts and imposes tighter limits to slow down credential-stuffing and brute-force attacks. This limiter is on by default. Only POST submissions count against it, so loading the login page, OIDC redirect bounces, and refreshes never consume the budget.

The defaults - 30 POST attempts per 5-minute window, blocked for 10 minutes - are tuned to tolerate a shared office NAT (many users behind one public IP) while still stopping a sustained brute-force run. The limiter is keyed by client IP + submitted username, so a block triggered against one username never locks out other users who share the same source IP (a shared NAT, or an unconfigured reverse proxy). Behind a reverse proxy, still set server.trustedProxies so the IP half of the key is the real client rather than the proxy - the server logs a one-time SECURITY warning when it sees X-Forwarded-For traffic with no trusted proxies configured.

Example configuration

"rateLimit": {
  "htmxErrorPath": "/error",
  "ip":              { "enabled": true,  "maxRequests": 300,  "windowSeconds": 60,  "blockDurationSeconds": 120 },
  "duplicateRequest":{ "enabled": true,  "maxRequests": 30,   "windowSeconds": 60,  "blockDurationSeconds": 300 },
  "unauthenticated": { "enabled": true,  "maxRequests": 600,  "windowSeconds": 60,  "blockDurationSeconds": 300 },
  "authenticated":   { "enabled": false, "maxRequests": 1200, "windowSeconds": 60,  "blockDurationSeconds": 120 },
  "login":           { "enabled": true,  "maxRequests": 30,   "windowSeconds": 300, "blockDurationSeconds": 600 }
}

Request and Connection Caps

Rate limits bound how frequently a single client may act. Caps bound the total load on the host regardless of how many clients there are, so the process degrades gracefully - shedding requests with a clean error - instead of collapsing when a burst (legitimate or hostile) tries to exhaust memory, connections, or file descriptors. They complement, and do not replace, the rate limiters and the slowloris read/idle timeouts.

Three independent caps live under server:

Setting Default What it bounds
server.maxConcurrentRequests 256 Requests handled at once. Over the cap, a request gets 503 Service Unavailable + Retry-After immediately rather than queueing. The single highest-value control.
server.maxConnections 1024 Total accepted TCP connections. At the cap the server stops accepting until one closes.
server.maxConnectionsPerIP 0 (off) Simultaneous connections from one direct-peer IP; excess connections are closed.

0 disables any of them.

Sizing. Keep maxConcurrentRequests at or below what the database pool (database.maxOpenConns) can service without queueing forever, and within available RAM ÷ worst-case per-request memory. It is set below maxConnections on purpose: the in-flight cap trips first, so the host sheds cleanly with a 503 before the raw connection cap starts refusing accepts.

maxConnectionsPerIP is a direct-exposure control and is off by default. It uses the direct connection IP (below any X-Forwarded-For handling), so behind a reverse proxy every connection appears to come from the proxy and behind a shared client-side NAT many users collapse onto one IP - either way a per-IP cap would wrongly throttle legitimate traffic. Leave it 0 when a proxy or a shared NAT is in front of the userbase; enable and size it only for genuinely direct exposure.

Shed 503 responses are not logged per-request: a flood is exactly when the cap fires, and a log line per shed would amplify the attack into log volume. Watch the 503/Retry-After rate through your monitoring instead.

Production & Security Hardening

HTMQL aims to be secure by default - parameterized SQL, auto-escaped output, deny-by-default authorization, a safe upload denylist, login brute-force protection, and bounded database load are all active without configuration. The settings below are the ones that still need a deliberate choice before a deployment is exposed to untrusted users or heavy traffic.

Built-in protections (no configuration required)

These are always on; they are listed so you know what you do not have to set up:

  • Parameterized SQL for every @parameter, with quote- and comment-aware binding. (Keep it that way: never feed a parameter into EXEC/sp_executesql string-building - see SQL Parameters.)
  • Contextual output escaping via Go's html/template (and the br helper escapes too).
  • Deny-by-default page authorization once any +role rule is present.
  • Same-site-only redirects (the redirect alias and login return_to), unless you opt into pages.externalRedirects.
  • Email address-header sanitization (no header injection).
  • Storage path-traversal containment and uploaded-filename sanitization across local, SFTP, and S3 locations.
  • X-Content-Type-Options: nosniff plus safe-inline serving of stored files (only images and PDF render inline; anything else is forced to download).
  • X-Frame-Options: SAMEORIGIN and Referrer-Policy: strict-origin-when-cross-origin on every response, HSTS when running over TLS, and an optional Permissions-Policy via server.permissionsPolicy.
  • Hardened session cookies - HttpOnly, SameSite=Lax, and Secure + __Host- under TLS - with server-side expiry and an idle timeout.
  • Login rate limiting, generic authentication failures, and secret redaction in logs and the admin view.

Settings to review before going live

Goal Setting(s) Default Recommendation for public / production
Encrypt all traffic server.certFile, server.keyFile off Always set both - enables HTTPS, the Secure/__Host- cookie, and HSTS.
Automate certificates server.tls.acme.* off For direct public exposure, enable ACME instead of manual certs so HTTPS auto-renews with no expired-cert outage. See Automatic certificates (ACME / Let's Encrypt).
Block anonymous floods rateLimit.unauthenticated.* on Leave on for direct public exposure; validate maxRequests against your busiest NAT egress IP's morning-rush logs and tune.
Limit heavy authenticated users rateLimit.authenticated.* off Optional; enable if a real user is found hammering. Keyed per-user, so it never false-positives a shared NAT.
Throttle general floods rateLimit.ip.* off Broad per-IP backstop; the unauthenticated limiter supersedes it for public exposure. Enable if you also want a coarse global cap.
Catch callback / refresh loops rateLimit.duplicateRequest.* off Enable, especially if pages use the execute alias.
Brute-force protection rateLimit.login.* on Leave on; tune the window only if behind a shared-IP proxy.
Correct client IP behind a proxy server.trustedProxies none Set to the proxy CIDR - required for rate limiting and IP binding to work. See Reverse Proxying.
Restrict what can be uploaded uploads.* safe denylist Tighten to an allowlist (allowedExtensions / allowedMimeTypes) if you accept only known types.
Filter downloads / email attachments downloads.*, email.attachments.* safe denylist Leave the denylist on; tighten if appropriate.
Bound database load database.maxOpenConns, database.queryTimeoutSeconds, database.maxResultMB 25 / 30s / 256MB Keep enabled; size to your server. See Connection Pool and Query Limits.
Shed load gracefully server.maxConcurrentRequests, server.maxConnections on (256 / 1024) Leave on and size to the host so a burst sheds with 503 instead of exhausting resources. See Request and Connection Caps.
Cap connections per client IP server.maxConnectionsPerIP off Enable only for direct exposure without a front proxy or shared client NAT; leave 0 otherwise (it would throttle a proxy/NAT that aggregates many users under one IP).
Idle logout server.idleMinutes 240 (4h) Lower for sensitive apps; 0 disables.
Optional session IP binding auth.sessionIPBinding.* off Enable for a soft "different network → re-login" signal; needs trustedProxies behind a proxy.
Lock down external programs execute.enabled, execute.allowedExecutables off / any Keep execute off unless needed; when on, always set an allowlist.
Restrict redirects pages.externalRedirects same-site only Leave empty unless you intentionally hand off to named external hosts.
Disable unused browser features server.permissionsPolicy no header Set to camera=(), microphone=(), geolocation=() if no page uses those features.
Restrict file permissions (Unix) logging.file.fileMode/dirMode, storage.locations.<name>.fileMode/dirMode OS default Set 0600/0700 on shared hosts so other local accounts can't read logs/uploads.
Hide error internals development off Never enable in production - it surfaces SQL/template errors to the browser.
Avoid accidental open pages auth.defaultAllow off Leave false so a page with no +role rule is denied, not public.

Go-live checklist

  1. TLS configured (certFile + keyFile, or automatic server.tls.acme); confirm the session cookie is Secure/__Host- and HSTS is present.
  2. development is false.
  3. IP and duplicate-request rate limiters enabled; server.trustedProxies set if behind a proxy.
  4. Upload / download / attachment filters reviewed (denylist on, or an allowlist set).
  5. Database limits sized for the host (maxOpenConns, queryTimeoutSeconds, maxResultMB); request/connection caps (server.maxConcurrentRequests, server.maxConnections) on and sized, with maxConnectionsPerIP enabled only for direct exposure.
  6. execute disabled, or enabled with an allowedExecutables allowlist.
  7. auth.defaultAllow is false; sensitive pages carry explicit +role rules, and per-user records are protected with a guard (see Row-Level Security).
  8. Database connection encrypted (database.encryption) where the database is remote.
  9. Log file in a protected location; restrictive file modes on shared hosts.
  10. Run the service under a least-privilege account (not LocalSystem / root).