Authentication
Protect pages with built-in auth or an external proxy.
Overview
lazysite ships with built-in cookie-based authentication as the default
path. The same mechanism supports drop-in replacement by any external
auth proxy that sets X-Remote-* headers (Authentik, Authelia, etc.).
The processor reads the same auth headers regardless of which model is in use. Protected pages, group checks, and TT variables behave identically.
Built-in auth
How it works
lazysite-auth.pl authenticates users against a flat-file user
database, sets a signed HMAC cookie on success, and translates that
cookie into X-Remote-User/X-Remote-Groups headers for the
processor on subsequent requests.
On localhost, a user entry with no password hash allows password-less sign-in. This is a development convenience; in production, every account must have a password.
Apache setup
Configure Apache to route requests through the auth wrapper before the processor:
FallbackResource /cgi-bin/lazysite-auth.pl
The auth wrapper reads the cookie, populates auth headers, and hands
off to lazysite-processor.pl if the request is authenticated (or
public).
User management
Use the manager Users page, or the lazysite-users.pl CLI:
perl tools/lazysite-users.pl --docroot /path/to/public_html \
add alice secretpassword
perl tools/lazysite-users.pl --docroot /path/to/public_html \
group-add alice admins
User management commands
add USERNAME PASSWORD Add a new user
passwd USERNAME NEWPASSWORD Change password
remove USERNAME Remove user and group memberships
list List all users
group-add USERNAME GROUP Add user to group
group-remove USERNAME GROUP Remove user from group
groups List all groups and members
File formats
Users (lazysite/auth/users):
alice:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
bob:5994471abb01112afcc18159f6cc74b4f511b99806da59b3caf5a9c173cacfc5
Each line is username:sha256hex. Lines starting with # are comments.
A user line with no hash (just username:) allows passwordless sign-in
on localhost only.
Groups (lazysite/auth/groups):
admins: alice
lazysite-admins: alice
editors: alice, bob
members: alice, bob, carol
Each line is groupname: user1, user2, ....
Managing users without the script
The users file is plain text with SHA256 hex hashes. Generate a password hash:
echo -n 'mypassword' | sha256sum | cut -d' ' -f1
Add a user by appending to the file:
echo "alice:$(echo -n 'mypassword' | sha256sum | cut -d' ' -f1)" \
>> lazysite/auth/users
Or with Perl (if sha256sum is not available):
perl -MDigest::SHA=sha256_hex -e 'print sha256_hex("mypassword")'
Groups are plain text too - edit lazysite/auth/groups in any text
editor. Set permissions after editing:
chmod 640 lazysite/auth/users
chmod 644 lazysite/auth/groups
Login and logout
The starter includes login.md and logout.md. The login form POSTs
to /login and logout is at /logout. On successful login a signed
cookie is set and the user is redirected to the original page (via the
next parameter).
Cookie security
- HttpOnly (not accessible via JavaScript)
- SameSite=Lax
- Secure flag when HTTPS is active
- HMAC-SHA256 signed with an auto-generated secret
- 24-hour expiry
The HMAC secret lives at lazysite/auth/.secret (chmod 0600).
Dev server
The dev server auto-detects built-in auth when lazysite/auth/users
exists and uses the auth wrapper automatically.
Self-service credentials and two-factor
The operator creates an account and sets its parameters; the user provisions their own secret. The operator never sets or handles a password. One primitive underlies every flow: a single-use, short-lived, hashed claim token.
Setup links (the user sets their own password)
On the Users page, an account card has Generate setup link next to Generate
credential. It mints a claim and shows a one-time URL (…/claim?u=<user>&c=<code>)
to hand over by any channel. The user opens it and the /claim page presents a
set-a-password form (interactive account) or a mint-and-reveal-token action
(machine account). The claim is consumed on success and expires after 24 h.
- Reset credential mints a fresh claim and revokes the current credential, so the account cannot authenticate until the new claim is redeemed - the forced reset for a lost or compromised secret. A plain setup link is additive (the old credential keeps working until redeemed).
- Disabled accounts and token-only (
uioff) accounts cannot redeem a set-password claim.
Forgot password (email, when SMTP is configured)
Where the SMTP plugin is configured and the account has an email, /login shows
a Forgot password? link → /forgot takes a username or email and mails a
set-password claim. The response is identical whether or not an account matched -
it never reveals whether an account or email exists. The reset email is recorded
in the audit trail (action forgot) against the matched account.
Two-factor (TOTP)
An interactive account can enrol TOTP two-factor (RFC 6238). Enrolment shows a
shared secret + an otpauth:// URI (QR) and issues one-time recovery codes;
after enrolment, login requires a valid 6-digit code (or a recovery code) before
the cookie is issued. Two-factor applies to interactive (password → cookie) login
only - token / WebDAV / connector auth is unchanged, since the token is already
the strong factor there.
# enrol from the CLI (or via the Users page card action)
perl tools/lazysite-users.pl --docroot /path/to/public_html mfa-enroll alice
The shared secret lives in user-settings.json under the same 0640/2770
protection as other credentials (the auth dir is off the web and group-restricted;
no at-rest encryption - an accepted tradeoff for self-hosting).
Account expiry
An account may carry expires_at (an epoch); after it, all authentication for
that account fails - time-boxed access for a contractor or a temporary partner.
Distinct from token expiry.
Security model
- Claims, connect codes, and recovery codes are 256-bit random, hashed at rest, single-use, short-TTL, and rate-limited per IP and per account.
- Generic responses everywhere -
/forgot,/claim, and the partner exchange never reveal whether an account, email, or claim is valid beyond success/failure. - HTTPS only; plaintext is refused (as for
/dav). - Material events are audited:
claim-redeem,forgot,token-exchange,token-rotate,user-claim-create,user-mfa-enroll, and the OAuth events (oauth-register,oauth-authorize,oauth-refresh,connect).
Protecting pages
Per-page auth
Set auth: in front matter:
---
title: Members Area
auth: required
---
Values:
required- user must be authenticated. Unauthenticated requests redirect to the login page.optional- auth headers are read if present but access is not restricted. Use for pages that show different content to logged-in users.none- no auth check. This is the default.
Group restrictions
---
title: Admin Dashboard
auth: required
auth_groups:
- admins
- editors
---
The user must be authenticated AND in at least one listed group. Users in the wrong group see the 403 page.
Site-wide default
Set auth_default: in lazysite/lazysite.conf:
auth_default: required
Pages without auth: in front matter inherit this value. Default
is none when not set. The login page is always accessible
regardless of the site-wide default.
Manager access
The manager at /manager uses the same auth mechanism. The
manager_groups setting in lazysite.conf lists the groups allowed
into the manager:
manager: enabled
manager_path: /manager
manager_groups: lazysite-admins
TT variables
These variables are available in page content and the view template:
[% authenticated %]-1if user is logged in,0otherwise[% auth_user %]- username[% auth_name %]- display name (from users file or proxy header)[% auth_email %]- email (from proxy header)[% auth_groups %]- array of group names
Example in a view template:
[% IF authenticated %]
<span>Signed in as [% auth_user %]</span>
<a href="/logout">Sign out</a>
[% ELSE %]
<a href="/login">Sign in</a>
[% END %]
Custom 403 page
Create 403.md in the docroot. These context variables are available:
[% auth_denied_reason %]-insufficient_groupswhen group check fails[% auth_required_groups %]- array of required group names[% auth_user %]- the authenticated username[% auth_name %]- display name
The 403 page is never cached.
External auth proxy
Any reverse proxy that sets HTTP headers works with lazysite. The processor reads these headers by default:
X-Remote-User- usernameX-Remote-Name- display nameX-Remote-Email- email addressX-Remote-Groups- comma-separated group list
Custom header names
If your proxy uses different header names, configure them in
lazysite/lazysite.conf:
auth_header_user: Remote-User
auth_header_name: Remote-Name
auth_header_email: Remote-Email
auth_header_groups: Remote-Groups
Authentik
# In Authentik proxy provider - forwarded headers:
# X-Remote-User: %(username)s
# X-Remote-Name: %(name)s
# X-Remote-Email: %(email)s
# X-Remote-Groups: %(groups|join(","))s
Apache with Authentik:
<Location />
RequestHeader set X-Remote-User "%{AUTHENTIK_USERNAME}e"
RequestHeader set X-Remote-Groups "%{AUTHENTIK_GROUPS}e"
</Location>
Authelia
Configure header names in lazysite.conf to match Authelia:
auth_header_user: Remote-User
auth_header_name: Remote-Name
auth_header_email: Remote-Email
auth_header_groups: Remote-Groups
nginx with Authelia:
location / {
auth_request /authelia;
auth_request_set $remote_user $upstream_http_remote_user;
auth_request_set $remote_groups $upstream_http_remote_groups;
proxy_set_header X-Remote-User $remote_user;
proxy_set_header X-Remote-Groups $remote_groups;
}
Cache behaviour
Protected pages (auth: required or with auth_groups:) are never
cached to disk and always include Cache-Control: no-store, private
in the response. This prevents authenticated content from being
served to unauthenticated users.