Protect pages with built-in auth or an external proxy.
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.Registration is by invitation: nothing public creates an account. A site that wants visitors to ask puts a native form on a public page - the submission is stored, quarantined and rate-limited like any other, and raises the operator's usual notification.
The operator then approves it. account-approve does in one step what would
otherwise be three: it creates the account with no password, places it in
whichever groups are flagged to take registrations, and mints the claim link to
send back. The person sets their own credential at /claim, so the operator
never sees, chooses or transmits a password.
lazysite users --docroot /path/to/public_html \
account-approve jbloggs --email jbloggs@example.org
Give it the address the registration came from. --email records it on the
account, and that one field is what turns the rest of the loop self-service: the
person can go to the sign-in page, ask for a link, and receive a fresh one at
their own address without the operator doing anything. Without it the claim link
you were handed is the only way in, for ever - so if you lose it, you are
minting another by hand.
The link goes to the address on the account, never to whoever asked, which is what makes asking safe: the request proves nothing, the mailbox does. The answer is the same whether or not an account exists, so the form cannot be used to find out who has one.
A malformed address creates nothing at all - the approval is refused before the account exists, rather than leaving a credential-less account behind for a typo.
Which group they land in is a group's own flag. On the Groups page, a group can be marked "add anonymous user registrations to this group". More than one group may carry it, and an approved account joins all of them.
Setting that flag is the strongest conferral this system has - it decides what a
person nobody has met holds on the day they are approved - so it passes the same
authority check as granting that group's capabilities one at a time. An operator
who could not confer manage_content cannot confer it to every future
registrant by ticking a box on a group that has it.
With no group flagged, an approved account joins nothing: it can sign in and
sees exactly what an anonymous visitor sees until an operator places it. That is
the safe default, because no shipped group grants only a login. A site that
wants approved registrations to be able to DO something makes a group for it -
write_data plus membership of the relevant tables' writable_by is the shape
for an app whose users write their own rows.
Approving requires full user management. It creates a top-level account, so a
delegated sub-manager (create_sub_users without manage_users) cannot call
it.
Where the SMTP extension 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.jsonor<docroot>-lazysite/auth/acls.jsonexists, route a request for an existing file to/cgi-bin/lazysite-auth.plinstead of serving it from disk.
Three details that are easy to get wrong:
Test both places. The second is where the store lives once a site's engine
tree has been moved out of the document root (lazysite migrate-engine-tree).
A rule that tests only the first stops firing the moment a site is migrated,
and nothing about the site looks different - that is how the shipped templates
behaved before 0.13.13.
Route at 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 %]
All five are HTML-escaped where they enter the page, so the example above is
safe with no filter and you should not add | html to them. Doing so
escapes an already-escaped value, and a display name of O'Brien renders as
O'Brien on the page.
The same is true of [% query.<name> %].
<script> blockThis does not work, and the way it fails is not obvious:
<script>
var me = '[% auth_name %]'; // WRONG
</script>
A browser decodes no HTML entities inside <script>, so the escaping that makes
these values safe in a page arrives in your JavaScript as literal entity text -
O'Brien rather than O'Brien. | html does not fix it and makes it
worse. There is no filter that is right here.
Put the value in an attribute and read it from there. The HTML parser decodes the escaping when it reads the attribute, so JavaScript receives the real characters:
<div id="viewer" data-user="[% auth_user %]" data-name="[% auth_name %]"></div>
<script>
var el = document.getElementById('viewer');
var me = el.dataset.name || el.dataset.user;
</script>
This is the idiom for any value a page's script needs from the request or
the viewer, and it is what the shipped search-results page and the manager's
editor both use.
A second reason to prefer it: a literal [% anywhere in a page - including
inside a regular expression in your own script - is read as a template directive
and fails the parse, and the whole page then renders with every [% %]
un-substituted. Keeping template syntax out of your script blocks avoids that
entirely.
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.