Auth Visibility
moo-theme includes an optional auth-aware visibility system. When enabled, pages with public: false in their frontmatter are hidden from navigation and the home page for unauthenticated visitors. The server still enforces access control — this is purely cosmetic.
Enabling auth
Add these params to hugo.toml:
[params]
authCheckUrl = "/auth-check" # endpoint that returns 200 (authed) or 401
signInUrl = "/oauth2/sign_in?rd=/" # optional, defaults to this
signOutUrl = "/oauth2/sign_out" # optional, defaults to this
Then provide a static/js/auth.js that checks the endpoint and sets a data attribute on <html>:
(function () {
fetch('/auth-check', { credentials: 'same-origin' })
.then(function (res) {
if (res.ok) {
document.documentElement.setAttribute('data-authenticated', '');
}
})
.catch(function () {});
})();
The theme includes this script automatically when authCheckUrl is set.
Marking pages
Set public: true or public: false in frontmatter:
---
title: "Secret Stuff"
public: false
---
When auth is not enabled (no authCheckUrl), the public field is ignored and everything is visible.
How it works
- Templates add
data-auth-requiredto nav items and home page cards for non-public pages. - JS fetches
authCheckUrl. If it returns 200, setsdata-authenticatedon<html>. - CSS hides
[data-auth-required]by default, reveals when[data-authenticated]is present.
[data-auth-required] { display: none; }
:root[data-authenticated] [data-auth-required] { display: unset; }
Sign-in / sign-out links
When auth is enabled, the sidebar footer shows a “Sign in” link (visible when signed out) and a “Sign out” link (visible when signed in). These use data-show-when="signed-out" and data-show-when="signed-in" attributes.
Server-side enforcement
The theme only handles visibility. You still need your reverse proxy to actually block access to protected pages and provide the /auth-check endpoint. Below are complete examples for Caddy and nginx, both using oauth2-proxy as the auth layer.
The auth-check endpoint
The JS makes a GET /auth-check request with cookies. Your reverse proxy needs to:
- Forward the request to oauth2-proxy’s
/oauth2/authendpoint - If oauth2-proxy returns 200: respond with 200 (user is authenticated)
- If oauth2-proxy returns 401: respond with 401 (no redirect, no HTML — just the status code)
This is important — the endpoint must not redirect to a sign-in page on failure. The JS expects a clean 200 or 401.
Caddy example
docs.example.com {
root * /var/www/docs
# oauth2-proxy sign-in, callback, sign-out
handle /oauth2/* {
reverse_proxy oauth2-proxy:4180
}
# Auth check — returns 200 or 401, no redirects
handle /auth-check {
forward_auth oauth2-proxy:4180 {
uri /oauth2/auth
@unauthorized status 401
handle_response @unauthorized {
respond 401
}
}
respond 200
}
# Public sections — no auth required
@public {
path /
path /index.html
path /blog /blog/ /blog/*
path /css/* /images/* /fonts/*
path *.css *.js *.woff *.woff2 *.png *.jpg *.svg *.ico
}
handle @public {
try_files {path} {path}/ {path}.html
file_server
}
# Everything else — require auth, redirect to sign-in on failure
handle {
forward_auth oauth2-proxy:4180 {
uri /oauth2/auth
copy_headers X-Auth-Request-User X-Auth-Request-Email
@unauthorized status 401
handle_response @unauthorized {
redir * /oauth2/sign_in?rd={scheme}://{host}{uri}
}
}
try_files {path} {path}/ {path}.html
file_server
}
}
Key points:
handle /auth-checkis separate from the public and protected handlers- On auth failure it returns
respond 401— not a redirect - Public paths are listed explicitly in the
@publicmatcher - Protected paths use
forward_authwith a redirect to the sign-in page - The
rdquery parameter preserves the original URL so the user returns to the right page after signing in
nginx example
server {
listen 443 ssl;
server_name docs.example.com;
root /var/www/docs;
index index.html;
# oauth2-proxy backend
location /oauth2/ {
proxy_pass http://oauth2-proxy:4180;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Auth check — returns 200 or 401, no redirects
location = /auth-check {
internal;
proxy_pass http://oauth2-proxy:4180/oauth2/auth;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-URI $request_uri;
}
# Expose auth-check to JS (wraps the internal location)
location = /_auth-check {
auth_request /auth-check;
auth_request_set $auth_status $upstream_status;
return 200;
error_page 401 = @auth_check_denied;
}
location @auth_check_denied {
return 401;
}
# Public sections — no auth
location / {
try_files $uri $uri/ $uri.html =404;
}
location /blog/ {
try_files $uri $uri/ $uri.html =404;
}
# Protected sections — require auth
location /networking/ {
auth_request /auth-check;
auth_request_set $auth_user $upstream_http_x_auth_request_user;
error_page 401 = @sign_in_redirect;
try_files $uri $uri/ $uri.html =404;
}
location /deployment/ {
auth_request /auth-check;
error_page 401 = @sign_in_redirect;
try_files $uri $uri/ $uri.html =404;
}
# Add more protected locations as needed...
location @sign_in_redirect {
return 302 /oauth2/sign_in?rd=$scheme://$host$request_uri;
}
}
Key points:
- nginx uses
auth_requestto subrequest oauth2-proxy - The
/auth-checklocation isinternal— only accessible viaauth_request /_auth-checkwraps it as a public endpoint for the JS to fetch- Set
authCheckUrlto/_auth-checkin yourhugo.toml(note the underscore) - Each protected location needs its own
auth_requestdirective — nginx doesn’t have Caddy’s catch-allhandlepattern error_page 401redirects to the sign-in page with the original URL preserved
oauth2-proxy configuration
Both examples assume oauth2-proxy is running with at minimum:
--upstream=static://200
--http-address=0.0.0.0:4180
--reverse-proxy=true
--set-xauthrequest=true
--cookie-secure=true
The --upstream=static://200 is important — oauth2-proxy isn’t proxying to a backend, it’s only handling auth. The reverse proxy (Caddy/nginx) serves the actual files.
If you want the sign-in page to redirect straight to your OIDC provider without an intermediate button, add --skip-provider-button=true. Note that this breaks the rd (redirect) parameter — after signing in, users will always land on / instead of the page they were trying to access. If you need the redirect to work, keep the provider button and consider customising the sign-in template to match your site’s look.