Skip to content

Authentication ​

Lamaste uses two separate authentication systems: mTLS client certificates for admin access, and Authelia TOTP two-factor authentication for end-user access to tunneled apps.

In Plain English ​

Lamaste protects two different things, and each uses a different type of lock.

The admin panel is protected by a client certificate — like a digital ID card that your browser presents automatically. Only you, the server operator, have this ID card. No username, no password, no login page. This is mTLS.

The tunneled apps (the web apps you expose to the internet) are protected by a separate system called Authelia. When a visitor tries to access your app, they see a login page where they enter a username, password, and a 6-digit code from an authenticator app on their phone (like Google Authenticator or Authy). This is TOTP two-factor authentication — something you know (password) plus something you have (your phone).

Think of it this way: the admin panel is your house's back door, opened only by a fingerprint scanner (the certificate). The tunneled apps are the front door, opened by a key and an alarm code (password plus TOTP).

These two systems are completely independent. Admin certificate holders do not automatically get access to tunneled apps, and Authelia users cannot access the admin panel.

There is also an optional built-in 2FA layer for the admin panel itself. When enabled, the admin must present their client certificate and enter a TOTP code to access the panel. This adds a second factor on top of mTLS, protecting against scenarios where the certificate file is compromised. Agents are exempt from this requirement — they authenticate with mTLS only.

For Users ​

Who uses what ​

PersonAccessesAuthentication method
You (the admin)Management panel at https://<IP>:9292Client certificate (mTLS), optionally + TOTP
Your usersTunneled apps at myapp.example.comUsername + password + TOTP code
AgentsPanel APIClient certificate (mTLS) — 2FA does not apply

Managing users ​

From the Users page in the management panel, you can:

  • Create users — set a username, display name, email, and password
  • Delete users — remove a user (you cannot delete the last user)
  • Reset TOTP — generate a new TOTP secret if a user loses their authenticator (separate step via POST /api/users/:username/reset-totp)
  • Update password — change a user's password

Creating a user ​

  1. Navigate to the Users page in the management panel
  2. Click "Add User"
  3. Enter a username, display name, email, and password
  4. Lamaste creates the user in users.yml and restarts Authelia
  5. Share the credentials with the user — they enroll in TOTP on first login

TOTP enrollment flow ​

TOTP enrollment is a separate step from user creation. There are two paths:

First-login enrollment: When a new user visits a Lamaste-protected app for the first time, Authelia presents a QR code during their initial login. The user scans it with their authenticator app to complete enrollment.

Admin-initiated reset: If a user loses their authenticator, the admin clicks "Reset TOTP" on the Users page, which calls POST /api/users/:username/reset-totp. This writes the new TOTP secret to Authelia's SQLite database via the authelia storage user totp generate CLI command. A QR code is displayed for the user to scan.

Admin creates user → User visits protected app
                   → Authelia prompts for TOTP enrollment on first login
                   → User scans QR code with authenticator app
                   → Authenticator generates 6-digit codes every 30 seconds

The QR code encodes a standard otpauth:// URI:

otpauth://totp/Lamaste:username?secret=BASE32SECRET&issuer=Lamaste&algorithm=SHA1&digits=6&period=30

How a user logs in ​

When a visitor browses to a tunneled app (e.g., https://myapp.example.com), nginx checks if they are authenticated via Authelia. If not:

  1. They are redirected to https://auth.example.com — the Authelia login portal
  2. They enter their username and password
  3. They enter the current 6-digit TOTP code from their authenticator app
  4. Authelia sets a session cookie and redirects them back to the app
  5. On subsequent visits (within the session), they are not prompted again

Session duration is 12 hours, with a 2-hour inactivity timeout. After that, the user must authenticate again.

What happens when a user loses their authenticator ​

If a user loses their phone or uninstalls their authenticator app, you (the admin) can reset their TOTP from the Users page. This generates a new TOTP secret and displays a new QR code for them to scan.

Optional panel 2FA (admin only) ​

You can enable a built-in TOTP 2FA layer for admin panel access. This is separate from Authelia (which protects tunneled apps) and adds a second factor on top of the mTLS client certificate.

How it works:

  1. Enable 2FA from the panel settings. A QR code is displayed for your authenticator app.
  2. On subsequent visits, after mTLS succeeds, a TOTP prompt appears. Enter the 6-digit code to proceed.
  3. A session cookie (lamaste_2fa_session) is set with a 12-hour absolute expiry and a 2-hour inactivity timeout. The cookie is HMAC-SHA256 signed using a sessionSecret stored in panel.json, and is marked HttpOnly, Secure, and SameSite=Strict.

Important behavior changes when 2FA is enabled:

  • The IP-based vhost (https://<IP>:9292) is disabled. Panel access is domain-only. This prevents session cookies from being scoped to an IP address.
  • Rate limiting applies: 5 failed TOTP attempts within 2 minutes trigger a 5-minute ban.
  • Agents are not affected — they authenticate via mTLS only and bypass the 2FA check entirely.

TOTP parameters: RFC 6238, SHA-1, 30-second period, 6 digits, +/-1 step clock drift tolerance, replay protection (each code can only be used once).

Configuration: Panel 2FA state is stored in panel.json under panel2fa: { enabled, secret, setupComplete }. The session signing key is stored as sessionSecret in the same file, generated with crypto.randomBytes on first 2FA setup.

Recovery: If you lose your authenticator, run sudo lamaste-reset-admin on the server. This clears 2FA, re-enables the IP-based vhost, and reverts to standard mTLS-only admin access. See Disaster Recovery for details.

For Developers ​

Authelia overview ​

Authelia is an open-source authentication and authorization server written in Go. Lamaste uses it specifically for TOTP two-factor authentication via the nginx forward-auth pattern.

Authelia runs as a systemd service on the VPS, binding to 127.0.0.1:9091. It is never exposed directly to the internet — nginx proxies auth.example.com to it.

Why Authelia ​

Several factors led to choosing Authelia:

  • Single binary — no runtime dependencies, simple deployment
  • Low memory — ~25MB RAM at idle, fitting the 512MB budget
  • File-based users — reads users.yml directly, no database required
  • TOTP built-in — native support for time-based one-time passwords
  • nginx forward-auth — designed for the auth_request integration pattern
  • bcrypt support — critical for the RAM constraint (see below)

bcrypt vs argon2id — the RAM constraint ​

Authelia supports both bcrypt and argon2id for password hashing. Lamaste must use bcrypt.

Argon2id is generally considered the stronger algorithm, but it is memory-hard by design. Each hash operation allocates ~93MB of RAM. On a 512MB droplet running multiple services, a single authentication attempt with argon2id can trigger the Linux OOM killer, crashing the entire server.

Bcrypt uses minimal memory (~4KB per hash) and is configured with cost factor 12, which provides strong resistance to brute-force attacks without memory pressure:

yaml
# From Authelia configuration
authentication_backend:
  file:
    path: /etc/authelia/users.yml
    password:
      algorithm: bcrypt
      bcrypt:
        cost: 12

Authelia configuration ​

The full configuration is written during onboarding provisioning (packages/lamaste-serverd/src/lib/authelia.js):

yaml
server:
  address: 'tcp://127.0.0.1:9091/'

log:
  level: info
  file_path: /var/log/authelia/authelia.log

identity_validation:
  reset_password:
    jwt_secret: <random-64-byte-hex>

authentication_backend:
  file:
    path: /etc/authelia/users.yml
    password:
      algorithm: bcrypt
      bcrypt:
        cost: 12

access_control:
  default_policy: two_factor

session:
  name: lamaste_session
  secret: <random-64-byte-hex>
  cookies:
    - domain: example.com
      authelia_url: https://auth.example.com
      default_redirection_url: https://example.com
  expiration: 12h
  inactivity: 2h

regulation:
  max_retries: 5
  find_time: 2m
  ban_time: 5m

storage:
  encryption_key: <random-64-byte-hex>
  local:
    path: /etc/authelia/db.sqlite3

notifier:
  filesystem:
    filename: /etc/authelia/notifications.txt

totp:
  issuer: Lamaste
  period: 30
  digits: 6

Key configuration choices:

  • server.address: 'tcp://127.0.0.1:9091/' — binds to localhost only; nginx handles public access
  • default_policy: two_factor — requires password + TOTP for all authenticated users
  • session.cookies — array format (Authelia v4.38+) specifying the domain and Authelia URL for session cookies
  • notifier.filesystem — writes notifications to a file instead of sending email (suitable for small-scale use)
  • totp.period: 30 — standard 30-second TOTP window

Users file format ​

Authelia reads user data from /etc/authelia/users.yml:

yaml
users:
  alice:
    displayname: alice
    password: $2b$12$xxxxx... # bcrypt hash
    email: alice@lamaste.local
    groups:
      - admins

Lamaste manages this file through the panel server API. All writes are atomic: content is written to a temporary file first, then moved into place. This prevents Authelia from reading a partially written file.

After every write to users.yml, the panel server reloads Authelia via systemctl restart authelia so the changes take effect.

Creating users programmatically ​

The panel server creates users by hashing the password with bcrypt and writing to users.yml:

javascript
// From packages/lamaste-serverd/src/lib/authelia.js
export async function createUser(username, password) {
  // Hash password using bcrypt with cost factor 12
  const hash = await bcrypt.hash(password, 12);

  // Read existing users or start fresh
  let usersData = { users: {} };
  try {
    const { stdout } = await execa('sudo', ['cat', AUTHELIA_USERS]);
    const parsed = yaml.load(stdout);
    if (parsed && parsed.users) usersData = parsed;
  } catch {
    // File doesn't exist — start fresh
  }

  usersData.users[username] = {
    displayname: username,
    password: hash,
    email: `${username}@lamaste.local`,
    groups: ['admins'],
  };

  await writeUsers(usersData);
  return { username, created: true };
}

TOTP secret generation ​

TOTP secrets are generated using crypto.randomBytes and encoded as base32:

javascript
// From packages/lamaste-serverd/src/lib/authelia.js
export function generateTotpSecret(username, opts) {
  const issuer = opts?.issuer ?? 'Lamaste';
  const secretBytes = crypto.randomBytes(20);
  const secret = base32Encode(secretBytes);
  const uri =
    `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(username)}?` +
    `secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`;
  return { secret, uri };
}

This function is also reused by the panel 2FA feature (lib/totp.js) with { issuer: 'Lamaste Panel' } to generate a distinct TOTP entry for admin panel access.

The otpauth:// URI follows the Google Authenticator Key URI format. This URI is rendered as a QR code in the panel client.

nginx forward-auth pattern ​

The forward-auth pattern is how nginx delegates authentication decisions to Authelia. For each request to a protected app, nginx sends a sub-request to Authelia asking "is this user authenticated?"

Here is the nginx configuration for a protected app vhost:

nginx
# Internal location — subrequest to Authelia
location /authelia {
    internal;
    proxy_pass http://127.0.0.1:9091/api/verify?rd=https://auth.example.com/;
    proxy_pass_request_body off;
    proxy_set_header Content-Length "";
    # This vhost's own hostname, never $http_host (see below)
    proxy_set_header X-Original-URL https://app.example.com$request_uri;
    proxy_set_header X-Forwarded-Method $request_method;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-Host app.example.com;
    proxy_set_header X-Forwarded-Uri $request_uri;
    proxy_set_header X-Forwarded-For $remote_addr;
}

# Main location — protected by auth_request
location / {
    auth_request /authelia;
    auth_request_set $user $upstream_http_remote_user;
    auth_request_set $groups $upstream_http_remote_groups;
    auth_request_set $name $upstream_http_remote_name;
    auth_request_set $email $upstream_http_remote_email;

    proxy_set_header Remote-User $user;
    proxy_set_header Remote-Groups $groups;
    proxy_set_header Remote-Name $name;
    proxy_set_header Remote-Email $email;

    proxy_pass http://127.0.0.1:PORT;
}

# Unauthenticated users are redirected to Authelia
error_page 401 =302 https://auth.example.com/?rd=https://app.example.com$request_uri;

Authelia decides by the URL it is asked about, so the vhost writes its own hostname into it. $http_host — the client's Host header — would let a client send the request line of this vhost (GET https://app.example.com/ HTTP/1.1, which nginx uses to select the vhost) with the Host of a hostname its rules allow, and be let in here. Lamaste's generated vhosts do this; see nginx Configuration.

The flow works like this:

1. Visitor requests myapp.example.com
2. nginx sends subrequest to Authelia's /api/verify endpoint
3. Authelia checks for a valid session cookie:
   a. Cookie present and valid → returns 200 with user headers
   b. No cookie or expired → returns 401
4. If 200: nginx proxies to the app with Remote-User/Groups/Name/Email headers
5. If 401: nginx redirects to auth.example.com with the original URL as ?rd= parameter
6. User authenticates on Authelia portal
7. Authelia sets session cookie and redirects back to the original URL
8. On the redirect, step 3a succeeds and the user sees the app

User headers ​

When authentication succeeds, Authelia returns user information in response headers. nginx captures these with auth_request_set and forwards them to the proxied app:

HeaderContentExample
Remote-UserUsernamealice
Remote-GroupsComma-separated groupsadmins
Remote-NameDisplay namealice
Remote-EmailEmail addressalice@lamaste.local

Your tunneled app can read these headers to identify the authenticated user without implementing its own authentication.

Atomic YAML writes ​

The users.yml file is read live by Authelia. To prevent Authelia from reading a partially written file, writes use atomic operations:

javascript
export async function writeUsers(usersData) {
  const yamlContent = yaml.dump(usersData, { lineWidth: -1 });
  // 0660: Authelia rewrites the file in place when a user changes a password.
  await writeAutheliaFile(AUTHELIA_USERS, yamlContent, 0o660);
}

The writeAutheliaFile helper writes a random temp file in /etc/authelia/, fsyncs it and renames it over the target. Since a rename on the same filesystem is atomic at the kernel level, Authelia never sees a half-written file. No sudo is involved: /etc/authelia belongs to the panel's lamaste user with group lamaste-authelia and the setgid bit, so new files belong to Authelia's group. Authelia itself runs as the unprivileged lamaste-authelia account, not root.

Storing a user's TOTP secret is the one Authelia operation the panel cannot do itself — the secret lives in Authelia's database, which is that account's file. The panel runs sudo lamaste-priv authelia-totp <username> with the secret on stdin (never on a command line), and the root helper runs authelia storage user totp generate as lamaste-authelia.

Secrets management ​

Authelia requires three secrets, all generated with crypto.randomBytes:

SecretPurposeStorage
jwtSecretSigns JWT tokens/etc/authelia/configuration.yml
sessionSecretEncrypts session cookies/etc/authelia/configuration.yml
storageEncryptionKeyEncrypts SQLite database/etc/authelia/configuration.yml

A copy is also stored in /etc/authelia/.secrets.json (mode 600) for reference. The configuration file itself is mode 600 to prevent unauthorized reading of these secrets. When an OpenID Connect provider is configured (below), .secrets.json also holds oidcHmacSecret.

OpenID Connect provider ​

Authelia is also the relay's OpenID Connect provider, at https://auth.<domain> (/.well-known/openid-configuration, /jwks.json, /api/oidc/token, /api/oidc/device-authorization, /api/oidc/userinfo — all served by the auth vhost's plain proxy location, without forward auth). The clients come from two places: every enabled plugin's manifest (oidcClients — e.g. Feria's feria-cli, a public device-code client so feria login authenticates through the relay) and the panel itself (lamaste-server, a confidential client-credentials client, when the registry credential uses auth.kind: oidc). The panel rewrites identity_providers.oidc after every plugin lifecycle change and every registry-credential change, restarting Authelia only when the section actually changed, and removes the section when no client is left (Authelia refuses an oidc section without clients).

yaml
identity_providers:
  oidc:
    hmac_secret: <random-64-byte-hex>
    jwks:
      - key_id: lamaste-oidc
        algorithm: RS256
        use: sig
        key: { { secret "/etc/authelia/oidc-issuer.pem" | mindent 10 "|" | msquote } }
    lifespans:
      device_code: 10 minutes
    claims_policies:
      feria-cli:
        access_token:
          - groups
          - preferred_username
    clients:
      - client_id: feria-cli
        client_name: Feria CLI
        public: true
        authorization_policy: two_factor
        consent_mode: explicit
        scopes: [openid, profile, groups]
        grant_types: ['urn:ietf:params:oauth:grant-type:device_code']
        token_endpoint_auth_method: none
        access_token_signed_response_alg: RS256
        requested_audience_mode: explicit
        claims_policy: feria-cli
        audience: ['https://feria.example.com']

Key choices:

  • The issuer key is Authelia's alone. /etc/authelia/oidc-issuer.pem (RSA 4096) is generated by create-lamaste as the lamaste-authelia account, mode 0600. The panel, which writes the configuration, cannot read it: the configuration carries a template expression instead of the key, and Authelia's unit (and lamaste-priv authelia-totp, which runs Authelia's CLI) sets X_AUTHELIA_CONFIG_FILTERS=template so Authelia expands it at load time. Because the whole file is then a Go template, the panel refuses to write any value containing {{ or }}. The panel reads the file back by swapping that one line for a placeholder before parsing it as YAML.
  • JWT access tokens. access_token_signed_response_alg: RS256 makes access tokens RFC 9068 JWTs a resource server (Feria) verifies against /jwks.json. groups and preferred_username are UserInfo claims by default; the per-client claims policy copies them into the access token when the client declares accessTokenClaims — only for claims whose scope was granted.
  • Confidential clients carry a pbkdf2-sha512 digest of their secret ($pbkdf2-sha512$310000$<salt>$<key>, salt and key in go-crypt's adapted base64: + written as ., no padding). The panel generates the secret, keeps the plaintext in /etc/lamalibre/lamaste/oidc-clients.json (0600), and shows it once.
  • Client credentials. For the panel's own lamaste-server client Authelia sets both sub and client_id to the client id (there is no end user), which is also why such a client must have authorization_policy: one_factor and no openid/offline_access scope — Authelia refuses the configuration otherwise. The panel calls the token endpoint on 127.0.0.1:9091 with X-Forwarded-Proto: https and X-Forwarded-Host: auth.<domain>, which is where Authelia takes the issuer from, so the JWT says iss: https://auth.<domain> exactly as through nginx.

See Registry Credential and OIDC Clients for the manifest fields and the API.

Safety rule: never delete the last user ​

The panel server API prevents deleting the last Authelia user. If only one user exists and you try to delete them, the API returns an error. This prevents a lockout scenario where no one can authenticate to reach the tunneled apps.

Source files ​

FilePurpose
packages/lamaste-serverd/src/lib/authelia.jsInstall, configure, user CRUD, TOTP generation
packages/lamaste-serverd/src/routes/management/users.jsUser management API endpoints
packages/lamaste-serverd/src/lib/nginx.jsApp vhost with Authelia forward-auth block
packages/lamaste-serverd/src/lib/certbot.jsTLS cert for auth.example.com subdomain
packages/lamaste-serverd/src/lib/totp.jsPanel 2FA TOTP verification and replay protection
packages/lamaste-serverd/src/lib/session.jsHMAC-SHA256 session cookie signing and validation
packages/lamaste-serverd/src/middleware/twofa-session.jsFastify plugin enforcing 2FA session for admin certs
packages/lamaste-serverd/src/routes/management/settings.jsPanel 2FA setup, verify, and disable endpoints

Quick Reference ​

Two authentication systems ​

SystemProtectsMethodSession
mTLSAdmin panelClient certificatePermanent (cert-based)
Panel 2FAAdmin panelmTLS + TOTP (opt-in, admin only)12h absolute, 2h inactivity (lamaste_2fa_session)
AutheliaTunneled appsPassword + TOTP12h expiry, 2h inactivity

Authelia service ​

PropertyValue
Binary/usr/local/bin/authelia
Config/etc/authelia/configuration.yml
Users/etc/authelia/users.yml
Database/etc/authelia/db.sqlite3
Log/var/log/authelia/authelia.log
Listen127.0.0.1:9091
Systemd unitauthelia.service
RAM usage~25MB

TOTP parameters ​

ParameterValue
AlgorithmSHA-1
Digits6
Period30 seconds
IssuerLamaste
Secret length20 bytes (160 bits)
EncodingBase32

Systemd commands ​

bash
# Check Authelia status
systemctl status authelia

# View recent logs
journalctl -u authelia -n 50 --no-pager

# Restart after users.yml changes
sudo systemctl restart authelia

API endpoints ​

MethodPathDescription
GET/api/usersList all users (without password hashes)
POST/api/usersCreate user with username, displayname, email, and password
PUT/api/users/:usernameUpdate user password or display name
DELETE/api/users/:usernameDelete user (not the last one)
POST/api/users/:username/reset-totpGenerate new TOTP secret

Password hashing ​

PropertyValue
Algorithmbcrypt
Cost factor12
Memory per hash~4KB
Why not argon2id~93MB per hash, causes OOM on 512MB VPS
  • mTLS — client certificate authentication for the admin panel
  • nginx Reverse Proxy — forward-auth configuration details
  • Security Model — authentication as part of defense-in-depth
  • Tunneling — how tunneled apps reach the internet

Released under the PolyForm Noncommercial License 1.0.0