*Authentication is not something each app builds on its own. It is a gate you stand up once and share.*
2-03 laid the foundation (SQLite, PostgreSQL). The gate is the first thing that goes on top of it. The gate, too, runs on the one machine handed to the AI in 2-02.
Every app checks "who is this?" at the door. Building that check per app is the prime case of reinventing the wheel, and the easiest to get wrong, because it sits at the center of security. So authentication, too, you don't write. You stand it up.
The gate follows the foundation
The reason is simple: every app you stand up after this shares the same identity (who). Documents, course booking, the core systems — users log in once.
But settle one principle here. *The gate holds only the minimal common thing — identity (authentication).* Authorization, "what you can do," is held by each app, each server, itself. The idea that "whoever passed the gate goes anywhere inside" — a single perimeter — is not the design here: breach one place and everything falls. Central minimal, defense distributed across each server. That is the form that is distributed and strong.
A homegrown login means hashing, sessions, token revocation, two-factor, password reset, and social login, and not one of them can be skimped. Standing up the identity check once and sharing it, while leaving the permission decision to each app, is faster, safer, and stronger.
Stand up PocketBase
The gate is PocketBase. A single Go binary carries authentication, an admin UI, REST / realtime APIs, and file storage. SQLite is built in, so there is no separate server to run. The project ships a single file and no Docker image (its own documentation says so). So, as 2-02 decided, place the file and register it with systemd.
- It lives in
/opt/pocketbase, with its data inpb_dataalongside - It listens on localhost only (
--http=127.0.0.1:8090). Caddy takes the outside (below) - It runs under the systemd unit the official guide recommends; the AI writes the unit
# after unpacking the GitHub Releases zip into /opt/pocketbase and registering it with systemd
sudo -u pocketbase /opt/pocketbase/pocketbase superuser create admin@example.com 'change-me'
The admin UI is at http://localhost:8090/_/. Users, login methods, and token
lifetimes are all configured from here. Not a line of code yet. The
administrator's address and password are values the human decides and supplies.
Open email, OAuth, and one-time codes
On PocketBase's users collection, enable the doors you need.
- Email + password — default, with verification and reset built in
- OAuth2 — add Google, Microsoft, GitHub as "log in" buttons
- One-time code (OTP) — passwordless entry with a number sent by email
- MFA — enforce two-factor for everyone, or just for administrators
Even mid-migration from Entra ID (formerly Azure AD), keep Microsoft in OAuth2 and users sign in with their existing Microsoft account. Move only the gate to your side; leave the user's experience unchanged.
Identity in the gate, business data in the warehouse
This is the crux of the design. Identity (who) lives in the gate; business data (what they did) lives in PostgreSQL (2-03). Separate the gate from the warehouse.
An app verifies the token's signature locally and pulls out user.id. There is
no per-request call to the gate, so the app keeps working even if the gate
blinks. PocketBase signs its tokens with a shared key (HS256) and has no public
key, so the collection's "Auth token secret" from the admin UI is shared with the
app. On the same machine, that is enough. Then whether this user may do this
operation is decided by the app itself.
# app side — verify the signature locally, decide permission yourself
user_id = verify_token(request.headers["Authorization"]) # signature-checked with the secret shared by the gate (no per-request call)
require(can_read_orders(user_id)) # "what you can do" is decided by this app
orders = pg.execute("SELECT * FROM orders WHERE user_id = %s", [user_id])
Identity verification is concentrated in the gate; *the permission decision stays in each app.* Share identity, but distribute defense across every server. That is defense in depth.
Identity is not something each app builds on its own — stand it up once and share it. But "what you can do" is guarded by each server itself.
Put a reverse proxy in front
Apps you build yourself are guarded by the gate's tokens. Packaged OSS such as the code host (Forgejo, 2-06) or mail (2-08), on the other hand, each carry their own login. PocketBase is an OAuth2 client, not a provider (OIDC), so it cannot bind packaged OSS directly. So the decisions are these.
- Packaged OSS keeps its own login and sits alongside
- One reverse proxy (Caddy) goes in front. Caddy's job is TLS and routing by name
- Binding everything into one login (SSO) comes when you add a layer that speaks OIDC — later
# Caddyfile — route by name; Caddy takes the certificates
auth.example.com { reverse_proxy localhost:8090 }
git.example.com { reverse_proxy localhost:3000 }
The example.com part is replaced with the domain name the human decides. Sharing
one identity comes first; full unification can wait. For most in-house use, the
gate plus each app's own login is enough.
Step off Entra ID
Microsoft Entra ID's free tier comes with Microsoft 365, as part of its per-seat bill. P1 and P2, which add conditional access and the like, stack a further charge per user (P1 $7, P2 $10 a month; list prices on 2026-10-05, billed yearly). Stand the gate up on your own side and step off that meter. The move can be gradual.
- Stand up PocketBase, keep Microsoft in OAuth2 (existing accounts still work)
- Build new apps against PocketBase tokens
- Move users into PocketBase's
usersin batches (bulk-load through the API; the AI writes the script) - Once everyone is across, remove Microsoft from OAuth2 — the Entra dependency is cut
You don't cut over in one stroke. Run both in parallel, and close the old gate only after the move is done (2-12).
Leaving is one point too
Joining was described above. Leaving is simpler still. Stop the account at the gate, and the person is out of everything. Documents, mail, meetings, the core systems all open with the same gate's token, so closing one point closes them all at once.
In the old world, offboarding was a major task because the keys were scattered across SaaS products — take inventory, then walk around turning them off one by one. The fear we saw in 2-01, that "close the gate and you are locked out of every layer at once," flips, when the key is in your own hands, into the reliability of offboarding.
How to check you are done
This chapter is done when these six hold.
- You open
http://localhost:8090/_/in a browser and log in as the superuser you created - You create one user in the admin UI, and that user can log in
- In the
userscollection you can switch email + password, OAuth2, OTP, and MFA on and off - An app verifies a token's signature without calling the gate and pulls out
user.id; stop the gate and that app keeps working - Opening
auth.example.comandgit.example.comin a browser reaches each app through Caddy - Stopping that user's account in the admin UI locks the person out of every app
systemctl status pocketbase # running under systemd
curl -s http://localhost:8090/api/health # does the gate answer
sudo systemctl stop pocketbase # see that the app still serves
What the human holds
Values the human supplies
- The domain names for the gate and each app (
auth.example.com,git.example.com) - The first administrator's email address and password
- The client IDs and secrets for Microsoft, Google, and GitHub used by OAuth2
- The token lifetime, and whether MFA is enforced for everyone or only administrators
- The list of users to move over from Entra ID
Actions the AI states before performing
- Removing Microsoft from OAuth2 (dropping the bridge to Entra)
- Stopping or deleting a user's account
- Pointing DNS records at these domain names
- Deleting or recreating
pb_data
Versions checked, and when
- PocketBase 0.40.4 (2026-09-12, the single file from GitHub Releases)
- Caddy, PostgreSQL (the business-data side) — no version pinned
- The procedure was written on 2026-07-02 and reviewed on 2026-10-05
- If a version has moved, have the AI confirm the official procedure before proceeding
Summary
On the foundation, the first gate.
- PocketBase — auth, admin UI, API, and file storage in a single binary (SQLite built in); place the file, run it under systemd
- Email / OAuth2 / OTP / MFA — open only the doors you need, from the admin UI
- Separate gate from warehouse — identity in PocketBase, business data in PostgreSQL
- Central minimal, defense at every server — the gate holds only identity; access control lives in each app (defense in depth)
- Reverse proxy — Caddy does TLS and routing by name. Packaged OSS keeps its own login alongside; SSO comes when an OIDC layer is added
- Step off Entra ID — bridge with OAuth2, then cut once the move is done
The only code written is a few lines that check a signature and decide a permission. Share identity; defend at every server. Next, inside that gate, we set up the home of the code (Forgejo) and bring repositories and CI to our own side.