Bliki Phase 3: Authentication & ACL
Phase 3 added user authentication and fine-grained access control, transforming Bliki from a personal publishing tool into a collaborative platform where content can be protected and selectively shared.
The Problem
Previously, all content was public. There was no way to:
- Restrict editing access to specific users
- Protect sensitive pages from public viewing
- Track who made changes to content
The solution needed to be lightweight — no external auth providers, no database, no session store. Just files, tokens, and role-based access control.
JWT-Based Authentication
Authentication uses JWT (JSON Web Tokens) with HS256 signing:
- Algorithm: HS256 (HMAC with SHA-256)
- Claim:
sub= username - Storage: HTTP-only cookie in the browser
- Lifetime: Configurable via
jwt_expiry(default:1h) - Secret: Configured via
jwt_secretinbliki.tomlorBLIKI_JWT_SECRETenv var
The token is issued on login and validated on each protected request. The POST /api/login endpoint accepts {username, password}, validates credentials against users.json, and returns a JWT cookie.
Security decision: jwt_secret is required if users exist. The server refuses to start with users in users.json but no jwt_secret configured. This prevents accidental token forgery with an empty secret.
User Storage
Users are stored in users.json in the same directory as bliki.toml:
{
"users": {
"admin": { "hash": "$2a$12$...", "role": "admin" },
"alice": { "hash": "$2a$12$...", "role": "editor" },
"bob": { "hash": "$2a$12$...", "role": "viewer" }
}
}
bcrypt hashing — passwords are hashed with golang.org/x/crypto/bcrypt at cost 12. The hash is generated on user creation and never stored in plaintext.
Atomic writes — users.json uses a .tmp → rename pattern to prevent corruption from concurrent writes.
Validation rules:
- Username: non-empty, ≤64 chars, no whitespace/slashes/quotes/special characters
- Password: non-empty, ≥8 characters
CheckPasswordreturns(false, nil)for both wrong password and non-existent user — preventing user enumeration
User Management CLI
bliki new user --name admin --password "secret" --role admin
The new user command creates or updates a user:
--name(required): username--password(required): plaintext password (hashed to bcrypt on storage)--role(optional, default:admin):admin,editor, orviewer--force: overwrite existing user
Three roles define the access hierarchy:
| Role | Read | Write |
|---|---|---|
admin |
everything | everything |
editor |
paths with editor/viewer rules | paths with editor rules |
viewer |
paths with viewer rules | nothing |
Each level inherits permissions below it (viewer < editor < admin).
ACL System
Access Control Lists are stored in acl.json in the same directory as bliki.toml:
{
"rules": [
{ "path": "blog/private.md", "role": "admin" },
{ "path": "admin/*", "role": "editor" },
{ "path": "internal/*", "role": "viewer" }
]
}
Matching logic:
- Admin users bypass all ACL checks (full access to everything)
- Non-admins find the longest matching pattern using
path.Match(Go standard library glob) - No matching rule → page is public
- Matching rule → check user role against required role
Longest-match-wins means specific patterns override broad ones:
Rule: "blog/*" → matches "blog/page.md" (role: viewer)
Rule: "blog/private.md" → matches "blog/private.md" (role: admin)
→ "blog/private.md" gets role "admin" (longer pattern wins)
→ "blog/other.md" gets role "viewer" (only the broader pattern matches)
Backward Compatibility
The authentication system is backward compatible:
- No users loaded → auth is bypassed entirely. Existing deployments without users continue working unchanged.
- Users exist but no
jwt_secret→ server exits with an error. This catches misconfigurations early.
Template Data
Authenticated users receive three new fields in template data:
.Username— the logged-in username (empty string if unauthenticated).Authenticated— boolean indicating if the user has a valid JWT.Role— the user's role (empty string if unauthenticated)
These enable conditional rendering in layouts:
{{ if .Authenticated }}
Welcome, {{ .Username }}!
{{ else }}
<a href="/login">Login</a>
{{ end }}
Frontend Components
Two web components handle auth state in the browser:
<bliki-logged-user> — toggles between login/logout links based on GET /api/user response. When authenticated, shows the username; when not, shows the login link.
<bliki-login> — form component with username/password inputs. Posts to /api/login, redirects on success, displays errors in an [error] element.
Endpoint Design
| Endpoint | Method | Auth Required | Purpose |
|---|---|---|---|
/api/login |
POST |
No | Issues JWT (accepts username/password) |
/api/logout |
POST |
No | Clears JWT session |
/api/user |
GET |
No | Returns { authenticated, username } |
/render/<path> |
GET |
Yes | Manual JWT validation + ACL check |
/api/render |
GET |
Yes | JSON content with template context |
/api/save |
POST |
Yes | Web editor save (JWT + ACL check) |
/api/lock |
GET |
Yes | Lock status for a path |
Protected pages use the /render/ endpoint which performs on-the-fly rendering with JWT validation and ACL checks. The /api/render endpoint serves the same content via JSON, populating .Pages and .Current for full template function availability.
Design Evolution
The auth system went through several iterations:
- Cookie-only auth → migrated to JWT for stateless validation
- Simple role check → evolved into ACL with longest-match-wins pattern matching
- Hardcoded paths → moved to config-driven
acl.json - Session store → eliminated entirely; JWT is self-contained with no server-side session
What's Next
Phase 3 delivered a complete authentication and access control system with JWT tokens, bcrypt passwords, role-based ACLs, and backward compatibility. Phase 3.5 built on this foundation: the inline web editor uses authentication to gate editing access, and SSE rebuild notifications are only sent to authenticated clients.