Getting SASSy: Stealing Login Patterns from Codeberg and Sourcehut
I'm building a SaaS. I want it to be as FOSS, Linux-native, and self-hosted as I can make it — which means at some point I have to stop and solve the least glamorous, most important problem in the whole thing: letting users log in.
Authentication is one of those areas where the wrong instinct is to invent
something. The right instinct is to go read how people who already solved it —
in the open, in production, for years — chose to do it. I picked two: Codeberg,
which runs Forgejo (a fork of Gitea), and
Sourcehut (sr.ht). Both are fully open source.
Both are self-hostable. Both have been running real accounts for real people for
a long time. And because they're open, I can read exactly how they do it instead
of guessing.
I came away with two questions. This post is built around them.
"How do Codeberg and Sourcehut handle this problem?"
The most useful thing I learned up front is that these two sit at opposite ends of one spectrum, and which one you copy depends entirely on whether you ever plan to split your app into multiple services.
Sourcehut treats identity as a service. meta.sr.ht is a standalone
account / billing / security hub. Every other service — git.sr.ht,
builds.sr.ht, lists.sr.ht — is an OAuth2 client of meta. Auth is a
network boundary you cross.
Forgejo treats identity as a module. One Go process owns login, sessions, tokens, and 2FA directly, but it delegates credential checking through a common auth-source interface (local password, LDAP, OIDC, PAM…). Auth is a plugin point inside one binary.
If you're one app today — like I am — Forgejo's model is the closer fit. But the discipline worth stealing is Sourcehut's: treat identity as the source of truth, behind an API, so you can extract it into its own service later without a rewrite.
Sourcehut, in detail
meta.sr.ht-api (Go, GraphQL) owns all auth state. The Python web frontend holds
no credentials — it just issues GraphQL queries. The UI is a client of the auth
API, not a co-owner of the auth database. That separation is the whole point.
- Login: password + TOTP 2FA. No WebAuthn. Public keys (SSH, PGP) are stored centrally in meta and consumed by the other services.
- OAuth2 is RFC 6749-conformant and deliberately hardened:
- Confidential clients only — public clients are prohibited. A native or CLI app must run a server component, or use a personal access token.
redirect_uriis not accepted at runtime — only the pre-registered URI is used. That kills a whole class of redirect attacks.scopeis mandatory (stricter than the spec). Scopes look likemeta.sr.ht/PROFILE:RO meta.sr.ht/SSH_KEYS:RW—service/SCOPE:access, where access isROorRW, enforced per GraphQL resolver via an@accessdirective.- Authorization codes are 16 hex chars, 5-minute TTL. Client secret is 64-byte base64.
- The token format is the clever bit. An access / personal token is a BARE-encoded payload — username, client ID, authorized scopes, expiry — that is HMAC-signed and base64'd. So the token is stateless: the server validates the signature without a database hit. The grant rides inside the token. Revocation is layered on top — a SHA hash of the token in Redis for instant lookup, plus a DB expiry timestamp as the durable fallback if the cache is lost.
- Service-to-service uses an
@internalauth type — single-use tokens encrypted with Sourcehut's internal private keys — which unlocks resolvers not exposed to the public. That's howgit.sr.httrusts a request that claims to come frommeta.
Forgejo / Codeberg, in detail
- Password hashing:
PASSWORD_HASH_ALGO, default PBKDF2-HMAC-SHA256 at ~310k–320k iterations; also argon2id, scrypt, bcrypt. Configurable length and complexity, plus an optional Have-I-Been-Pwned check (PASSWORD_CHECK_PWN) that rejects known-breached passwords at signup. Cheap, high-signal win. - Sessions: cookie + middleware. A
webAuth()→AuthShared()flow walks an authentication priority chain (try each method in order), then stashes the user in request context, gated on email confirmation / login-prohibition / forced-password-change. Session store is pluggable: memory / db / Redis / memcached. - API tokens are hashed in the DB with the same algorithm as passwords — never stored in plaintext — and scoped by permission, with an LRU cache (default 20) of recently-validated hashes so it doesn't re-run PBKDF2 on every request. A nice perf/security balance.
- 2FA: TOTP and WebAuthn/FIDO2 passkeys, including passwordless login. For
basic-auth API calls with 2FA on, the code goes in an
X-Gitea-OTP:header. - The auth-source abstraction is the real lesson: one interface, many backends — local password, OAuth2/OIDC client, LDAP (service-account search or simple DN-template bind), PAM, SMTP, SPNEGO/Kerberos, reverse-proxy header trust. Forgejo can also be an OAuth2/OIDC provider for other apps.
"Where is it all stored? A SQL table? Unix?"
This was my second question, and it's the one that actually changed how I'm going to build. Because the naïve mental model — "a Linux server, users, surely there are Unix accounts somewhere" — is wrong, and deliberately so.
The trick is to separate two things people conflate:
- Where the identity record lives → always a SQL row. Never
/etc/passwd. - What verifies the secret → usually a hash in that same SQL row, but
optionally delegated to Unix (
/etc/shadow) or an external directory (LDAP/OIDC).
That second point is the entire reason the auth-source abstraction exists.
Sourcehut: everything in PostgreSQL
Owned by meta.sr.ht:
| Concern | Where it lives |
|---|---|
| Identification (who you are) | Postgres user table: id, created, updated, username, email, user_type, url, location, bio. Single source of truth; other services have their own DBs but defer to meta. |
| Authentication (proving it) | Password hash + TOTP secret in meta's Postgres. SSH public keys in Postgres, cached in Redis. |
| Authorization (what you can do) | A stateless HMAC-signed token carrying username/client/scopes/expiry. Revocation via Redis (SHA of token) + Postgres expiry. |
| Unix accounts | None per user. One shared system user per service — you SSH as git@, hg@, builds@. |
Forgejo: everything in a configurable SQL DB
SQLite / MySQL / PostgreSQL, your choice:
| Concern | Where it lives |
|---|---|
| Identification | user table. |
| Authentication | Hashed password in the user row; 2FA secrets + WebAuthn credentials in their own tables; SSH keys in public_key; API tokens hashed in access_token. |
| Authorization | Token scopes stored with the token; repo/org/team permissions in SQL. Sessions in memory / db / Redis. |
| Unix accounts | None per user. A single git RUN_USER. |
So where does Unix actually come in?
Two places, both optional, both Forgejo:
- PAM verifies the password against
/etc/shadow(real Unix accounts) — but the identity row still lives in SQL. PAM only checks the secret. (It also has a footgun: an uploaded SSH key can bypass the PAM check entirely.) - LDAP / OIDC verify against an external directory — but Forgejo still
mirrors a
userrow into SQL so it has something to hang repos and permissions off of.
Even when the verifier is Unix or LDAP, the system of record is SQL. That's the rule.
The SSH trick worth stealing
Neither forge runs useradd per customer. So how does git push over SSH know
who you are with one shared system user?
sshd's AuthorizedKeysCommand. Instead of reading ~/.ssh/authorized_keys,
OpenSSH runs a program, hands it the public key being offered, and that program
looks the key up in the database and resolves it to a user.
- Sourcehut runs
gitsrht-dispatch: it checks Redis, then Postgres, thenmeta, caching the result back down the chain for the next push. - Forgejo either generates a managed
authorized_keyswith a forced-command per key, or usesAuthorizedKeysCommand(gitea keys …) to do the same lookup.
So OpenSSH's key-auth mechanism gets reused, but the "account" a key resolves
to is a database row, not a Unix user. One system user, a key→user lookup,
and you never touch /etc/passwd.
What I'm taking into my own build
Both forges converged on the same shape, so that's the shape I'm copying:
- One SQL
userstable is the source of truth for identity. Unix accounts don't enter into it. - Make the credential verifier pluggable behind that table: local hash now; LDAP/OIDC later when an enterprise customer asks for SSO — without touching the identity model.
- Authorization travels separately — either DB-stored scoped tokens (Forgejo: simpler) or stateless signed tokens with a revocation list (Sourcehut: fewer DB hits). I'll probably start with the former.
- Ship TOTP early, WebAuthn/passkeys later. That's the order both landed on.
- Add the HIBP breached-password check. Free, and it stops the dumbest account takeovers.
- If I ever do SSH/key access, copy the
AuthorizedKeysCommanddispatch trick. One shared system user, key→DB-user lookup. Neveruseraddper customer.
The headline, if you skipped to the bottom: it's SQL all the way down for identity and the canonical credentials, and the avoidance of per-user Unix accounts is a feature, not an oversight. The Linux box hosts the service; it does not host the users.
Sources I read while writing this: the Sourcehut API 2.0 dev log, OAuth 2.0 via meta.sr.ht, What happens when you push to git.sr.ht, Drew DeVault's Building interactive SSH applications, the Forgejo authentication docs, the Gitea config cheat sheet, and the Gitea SSH & key management writeup.
Comments