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.

Forgejo / Codeberg, in detail

"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:

  1. Where the identity record lives → always a SQL row. Never /etc/passwd.
  2. 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:

ConcernWhere 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 accountsNone 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:

ConcernWhere it lives
Identificationuser table.
AuthenticationHashed password in the user row; 2FA secrets + WebAuthn credentials in their own tables; SSH keys in public_key; API tokens hashed in access_token.
AuthorizationToken scopes stored with the token; repo/org/team permissions in SQL. Sessions in memory / db / Redis.
Unix accountsNone per user. A single git RUN_USER.

So where does Unix actually come in?

Two places, both optional, both Forgejo:

  1. 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.)
  2. LDAP / OIDC verify against an external directory — but Forgejo still mirrors a user row 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.

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:

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