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

The HMAC secret lives at lazysite/auth/.secret (chmod 0660 - owner + group, never world, so the site user's CLI tools and the web-server CGI can both use it whichever minted it first).

Sessions

A session is the signed cookie itself - there is no server-side session store on the request path. Two small side files make sessions visible and revocable: at login the cookie payload carries a random session id and one line (who / when / IP / device) is appended to lazysite/auth/sessions.jsonl (self-pruned after 24 h), and cookie verification consults lazysite/auth/revoked.json when it exists. The manager Sessions page lists the live sessions and can sign out a single session, all of one user's sessions ("Sign out everywhere"), or - by rotating the signing secret - everyone at once. Losing the registry only degrades the listing; cookies minted before this feature cannot be listed but are still killable per-user or by rotation.

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.

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

Protecting pages

Per-page auth

Set auth: in front matter:

---
title: Members Area
auth: required
---

Values:

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.

It applies to pages, not to files. A .html with no Markdown source, a PDF, an image or a downloadable archive is not a page - it has no front matter, so there is nothing for this setting to inherit into. auth_default: required will bounce every page to the login form and still serve those files to anyone who knows the path. Protecting them is the next section, and it is a separate, explicit act.

Protecting static files

A file with no page source is protected by giving it an entry in lazysite/auth/acls.json - the same per-file access list the manager, WebDAV and the MCP connector already use. A read list is what protects it:

{
  "private/brief.pdf": { "read": ["alice", "@staff"] },
  "private":           { "read": ["@staff"] }
}

Three behaviours worth knowing before you rely on it:

To see what is already restricted, and what has actually been refused, see Auditing access below.

Protecting a whole section

The same folder entry gates the section's pages, not only its files. To hold back an unfinished area, write one entry:

{ "upcoming": { "read": ["@editors"] } }

Every page under /upcoming/ now requires an editor, and so does every image and PDF in it - one rule, one place. No page in the section needs auth: front matter, and a page that carries auth: none does not escape the section gate: a section you can hold back only if every page inside it agrees is not a section gate at all.

Publishing is deleting the entry. Remove it and the whole subtree goes public in one act - no per-page edits, no partially-released section.

A page under a gated prefix is never written to the shared HTML cache, so a render for a permitted user cannot leak to the next anonymous visitor.

Holding a section back before launch

A protected section answers with a login redirect, which tells anyone who tries the URL that it exists. For a section that is not ready to be known about - an unlaunched product, a client area before announcement - add draft:

{ "upcoming": { "read": ["@editors"], "draft": true } }

That changes two things:

Editors on the read list preview it normally by signing in. With no read list, any signed-in user may preview it and the public still cannot - draft deliberately breaks the usual "no read list means anyone" rule, because a draft that was public would not be a draft.

Publishing is removing draft - or removing the entry entirely, which also drops the access gate. The section goes live and enters the sitemap on the next render.

Your web server has to co-operate

This is the part that catches people. A web server answers a request for an existing file from disk without consulting anything - that is what web servers are for. When it does, lazysite never sees the request and no ACL can apply.

So the front end has to be told: when this site has an ACL store, hand existing files to lazysite instead of serving them. The shipped Apache and nginx templates and the built-in dev server already do this, and there is nothing to configure - install or regenerate the vhost and it is in place.

If you run any other web server - Caddy, lighttpd, a CDN or a reverse proxy in front - you must add the equivalent rule yourself, or ACLs on static files will silently do nothing. The rule is:

If <docroot>/lazysite/auth/acls.json exists, route a request for an existing file to /cgi-bin/lazysite-auth.pl instead of serving it from disk.

Two details that are easy to get wrong:

Verify it before trusting it. With an ACL in place, request a protected file while signed out:

curl -sSI https://example.com/private/brief.pdf

A 302 to the login page (or a 403) means the rule works. A 200 means your web server is still answering from disk and the ACL is being ignored.

Auditing access

Two questions, two answers.

What is restricted right now - read the store directly:

jq -r 'to_entries[] | select(.value.read != null and (.value.read | length) > 0)
       | "\(.key)\t\(.value.read | join(","))"' lazysite/auth/acls.json

Anything listed is refused to everyone outside its list. This matters after an upgrade: an entry originally written to keep other editors out of a file now also keeps anonymous visitors out of it, which is usually the intention and occasionally is not.

What has actually been refused - the access log flags an access refusal with "ar":1, because no status code can express it (the anonymous case is a 302 to the login page, identical to any other redirect):

grep -h '"ar":1' lazysite/logs/access-*.jsonl | jq -r .p | sort | uniq -c | sort -rn

The same data appears as auth_refused in the analyse_visitors report, so an AI assistant with the analytics capability can answer this without shell access. A path there that you believe is public is the signal to check its ACL entry.

Who may grant what

A capability is conferred by turning it on for a group. Two rules govern who may do that:

Removing a capability is always allowed - that is de-escalation and needs no authority.

Grant authority exists so a delegate does not have to hold a capability merely to hand it to someone else. An agency sub-admin who manages an AI agent should be able to grant the agent mcp without carrying mcp on their own account, which would enlarge their surface for a purely administrative act:

# operator only
perl tools/lazysite-users.pl --docroot /path/to/public_html \
  group-set client-admins grantable mcp,api

Members of client-admins may now confer mcp and api on the groups they manage, and still do not hold either themselves.

Setting grantable is operator-only, and that is what makes it safe: grant authority is conferred from above and never self-assumed. A delegate that could widen its own grant authority would have no ceiling at all. Making a group a manager group (manager) is operator-only for the same reason.

Upgrading from before 0.10.5: there was no ceiling - manage_users alone allowed conferring any capability, including on a group the delegate belonged to. If your delegates rely on that, give them explicit grant authority for the capabilities they legitimately hand out; otherwise those grants now refuse, and the refusal names the command that fixes it.

Manager access

The manager at /manager uses the same auth mechanism. Access is the ui capability, granted through a group on the manager Groups page (the seeded lazysite-admins group carries it):

manager: enabled
manager_path: /manager

Capabilities on groups are the mechanism of record. (The legacy manager_groups: conf key is retired: on upgrade any group it named receives its capabilities explicitly and the conf line is removed.)

TT variables

These variables are available in page content and the view template:

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:

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:

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.

Further reading