Summit of SummitsReturn to the Source
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.
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.
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).
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
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
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, ....
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
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).
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).
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.
The dev server auto-detects built-in auth when lazysite/auth/users
exists and uses the auth wrapper automatically.
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.
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.
ui off) accounts cannot redeem a
set-password claim.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.
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).
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.
/forgot, /claim, and the partner exchange
never reveal whether an account, email, or claim is valid beyond success/failure./dav).claim-redeem, forgot, token-exchange,
token-rotate, user-claim-create, user-mfa-enroll, and the OAuth events
(oauth-register, oauth-authorize, oauth-refresh, connect).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.---
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.
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.
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"] }
}
@name is a group.Three behaviours worth knowing before you rely on it:
owner does not protect anything. Ownership is not a
read restriction here, and it is not one in the manager either. You need a
read list.403. Protected files are never stored by a
shared cache.To see what is already restricted, and what has actually been refused, see Auditing access below.
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.
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:
sitemap.xml, llms.txt, the
feeds, and any scan: page list. That is unconditional: a registry file is
generated once and then served to everyone from disk, so a draft page listed in
it is published no matter what the page itself answers.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.
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.jsonexists, route a request for an existing file to/cgi-bin/lazysite-auth.plinstead of serving it from disk.
Two details that are easy to get wrong:
lazysite-auth.pl, not at the processor. The auth wrapper
validates the session cookie and passes a trusted identity through. Pointing
straight at lazysite-processor.pl gives it no usable identity, so every
protected file bounces to the login page for everyone - including the people
entitled to read it.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.
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.
A capability is conferred by turning it on for a group. Two rules govern who may do that:
manage_users but not operator) may confer a
capability only if they hold it, or if an operator has put it in one of
their groups' grant authority.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.
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.)
These variables are available in page content and the view template:
[% authenticated %] - 1 if user is logged in, 0 otherwise[% auth_user %] - username[% auth_name %] - display name (from users file or proxy header)[% auth_email %] - email (from proxy header)[% auth_groups %] - array of group namesExample 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 %]
Create 403.md in the docroot. These context variables are available:
[% auth_denied_reason %] - insufficient_groups when group check fails[% auth_required_groups %] - array of required group names[% auth_user %] - the authenticated username[% auth_name %] - display nameThe 403 page is never cached.
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 listIf 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
# 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>
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;
}
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.