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
| Person | Accesses | Authentication method |
|---|---|---|
| You (the admin) | Management panel at https://<IP>:9292 | Client certificate (mTLS), optionally + TOTP |
| Your users | Tunneled apps at myapp.example.com | Username + password + TOTP code |
| Agents | Panel API | Client 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
- Navigate to the Users page in the management panel
- Click "Add User"
- Enter a username, display name, email, and password
- Lamaste creates the user in
users.ymland restarts Authelia - 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 secondsThe QR code encodes a standard otpauth:// URI:
otpauth://totp/Lamaste:username?secret=BASE32SECRET&issuer=Lamaste&algorithm=SHA1&digits=6&period=30How 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:
- They are redirected to
https://auth.example.com— the Authelia login portal - They enter their username and password
- They enter the current 6-digit TOTP code from their authenticator app
- Authelia sets a session cookie and redirects them back to the app
- 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:
- Enable 2FA from the panel settings. A QR code is displayed for your authenticator app.
- On subsequent visits, after mTLS succeeds, a TOTP prompt appears. Enter the 6-digit code to proceed.
- 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 asessionSecretstored inpanel.json, and is markedHttpOnly,Secure, andSameSite=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.ymldirectly, no database required - TOTP built-in — native support for time-based one-time passwords
- nginx forward-auth — designed for the
auth_requestintegration 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:
# From Authelia configuration
authentication_backend:
file:
path: /etc/authelia/users.yml
password:
algorithm: bcrypt
bcrypt:
cost: 12Authelia configuration
The full configuration is written during onboarding provisioning (packages/lamaste-serverd/src/lib/authelia.js):
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: 6Key configuration choices:
server.address: 'tcp://127.0.0.1:9091/'— binds to localhost only; nginx handles public accessdefault_policy: two_factor— requires password + TOTP for all authenticated userssession.cookies— array format (Authelia v4.38+) specifying the domain and Authelia URL for session cookiesnotifier.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:
users:
alice:
displayname: alice
password: $2b$12$xxxxx... # bcrypt hash
email: alice@lamaste.local
groups:
- adminsLamaste 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:
// 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:
// 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:
# 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 appUser 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:
| Header | Content | Example |
|---|---|---|
Remote-User | Username | alice |
Remote-Groups | Comma-separated groups | admins |
Remote-Name | Display name | alice |
Remote-Email | Email address | alice@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:
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:
| Secret | Purpose | Storage |
|---|---|---|
jwtSecret | Signs JWT tokens | /etc/authelia/configuration.yml |
sessionSecret | Encrypts session cookies | /etc/authelia/configuration.yml |
storageEncryptionKey | Encrypts 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).
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 bycreate-lamasteas thelamaste-autheliaaccount, mode0600. The panel, which writes the configuration, cannot read it: the configuration carries a template expression instead of the key, and Authelia's unit (andlamaste-priv authelia-totp, which runs Authelia's CLI) setsX_AUTHELIA_CONFIG_FILTERS=templateso 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: RS256makes access tokens RFC 9068 JWTs a resource server (Feria) verifies against/jwks.json.groupsandpreferred_usernameare UserInfo claims by default; the per-client claims policy copies them into the access token when the client declaresaccessTokenClaims— 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-serverclient Authelia sets bothsubandclient_idto the client id (there is no end user), which is also why such a client must haveauthorization_policy: one_factorand noopenid/offline_accessscope — Authelia refuses the configuration otherwise. The panel calls the token endpoint on127.0.0.1:9091withX-Forwarded-Proto: httpsandX-Forwarded-Host: auth.<domain>, which is where Authelia takes the issuer from, so the JWT saysiss: 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
| File | Purpose |
|---|---|
packages/lamaste-serverd/src/lib/authelia.js | Install, configure, user CRUD, TOTP generation |
packages/lamaste-serverd/src/routes/management/users.js | User management API endpoints |
packages/lamaste-serverd/src/lib/nginx.js | App vhost with Authelia forward-auth block |
packages/lamaste-serverd/src/lib/certbot.js | TLS cert for auth.example.com subdomain |
packages/lamaste-serverd/src/lib/totp.js | Panel 2FA TOTP verification and replay protection |
packages/lamaste-serverd/src/lib/session.js | HMAC-SHA256 session cookie signing and validation |
packages/lamaste-serverd/src/middleware/twofa-session.js | Fastify plugin enforcing 2FA session for admin certs |
packages/lamaste-serverd/src/routes/management/settings.js | Panel 2FA setup, verify, and disable endpoints |
Quick Reference
Two authentication systems
| System | Protects | Method | Session |
|---|---|---|---|
| mTLS | Admin panel | Client certificate | Permanent (cert-based) |
| Panel 2FA | Admin panel | mTLS + TOTP (opt-in, admin only) | 12h absolute, 2h inactivity (lamaste_2fa_session) |
| Authelia | Tunneled apps | Password + TOTP | 12h expiry, 2h inactivity |
Authelia service
| Property | Value |
|---|---|
| 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 |
| Listen | 127.0.0.1:9091 |
| Systemd unit | authelia.service |
| RAM usage | ~25MB |
TOTP parameters
| Parameter | Value |
|---|---|
| Algorithm | SHA-1 |
| Digits | 6 |
| Period | 30 seconds |
| Issuer | Lamaste |
| Secret length | 20 bytes (160 bits) |
| Encoding | Base32 |
Systemd commands
# Check Authelia status
systemctl status authelia
# View recent logs
journalctl -u authelia -n 50 --no-pager
# Restart after users.yml changes
sudo systemctl restart autheliaAPI endpoints
| Method | Path | Description |
|---|---|---|
| GET | /api/users | List all users (without password hashes) |
| POST | /api/users | Create user with username, displayname, email, and password |
| PUT | /api/users/:username | Update user password or display name |
| DELETE | /api/users/:username | Delete user (not the last one) |
| POST | /api/users/:username/reset-totp | Generate new TOTP secret |
Password hashing
| Property | Value |
|---|---|
| Algorithm | bcrypt |
| Cost factor | 12 |
| Memory per hash | ~4KB |
| Why not argon2id | ~93MB per hash, causes OOM on 512MB VPS |
Related documentation
- 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