Security model
This page lists what the authentication bricks actually protect you from, and how.
Every claim here is enforced in code and pinned by a test in the brick itself. That matters because security promises written only in prose drift out of date quietly. The other reason this page exists is the last section: some things are still your job, and you should know which.
Passwords
Section titled “Passwords”Stored as argon2id hashes. Hashing happens inside
gouncer.NewUser, using cost parameters the library fixes, so there
is nothing for you to choose or tune. argon2id is deliberately slow
and memory hungry, which makes guessing passwords at scale expensive.
A password is never stored, only its hash.
Checking is constant-time. Comparing takes the same time whether the first character is wrong or only the last one is. A comparison that bailed out early would leak the answer bit by bit. A corrupted hash in the database never matches anything.
Login timing does not reveal which emails exist. When someone logs
in with an unknown email, the handler still hashes the supplied
password against a fixed dummy hash. Both cases do the same work and
return the same 401, so response timing gives nothing away.
Password data never travels upward. Once logged in, the request
carries an Identity holding only id, email and name. Admin listings
never read the hash column from the database at all.
Sessions
Section titled “Sessions”Session tokens are random and stored only as digests. The token is random bytes handed to the browser once. The database keeps a SHA-256 digest instead of the token. Anyone who steals a copy of the database gets no usable tokens.
The cookie is locked down. It is __Host- prefixed, HttpOnly,
Secure and SameSite=Lax. In practice that means: browsers tie it
to exactly one hostname and refuse it without HTTPS, page scripts
cannot read it, and a cross-site POST does not carry it. That last one
is most of your CSRF protection for a JSON API.
Note the precise limit of Lax. It withholds the cookie from
cross-site POSTs, but it still sends it when someone follows a link
from another site, which is a top-level GET. That is why the bricks
define no state-changing GET routes, and why you should not either.
Expiry is enforced twice. An expired session stops working immediately, because the check is in the database query. The reaper then deletes the dead rows on a schedule. The deletion is housekeeping rather than the security boundary.
Disabling an account kills its sessions in the same transaction. The flag and the session deletions commit together. So re-enabling an account later cannot bring a token back to life that was stolen while it was disabled.
The login endpoint
Section titled “The login endpoint”Failed logins are rate limited per client IP. Only 401 responses
count, so ordinary users are unaffected. If the counter itself fails,
the limiter refuses the request rather than letting it through.
X-Forwarded-For is trusted only from proxy ranges you configure
explicitly.
Request bodies are capped. The JSON decoder limits body size before any password work happens and rejects extra content after the JSON. Without that, an endpoint that anyone can reach without logging in could be used to exhaust memory.
Errors do not leak internals. Known errors map to deliberately
generic messages. Anything unrecognised becomes internal error, so a
database or driver message never reaches a caller.
An account with no role has no authority. Every role check treats an empty role as a miss. A row someone inserts by hand, or an account from before your app used roles, cannot reach an administrator route until an operator gives it a role on purpose.
The last administrator cannot be removed. Disabling or demoting the last enabled account with a privileged role is refused. The store locks the privileged rows and counts again inside one transaction, so two administrators removing each other at the same time cannot both succeed.
Nobody changes their own role. The request’s identity is compared with the target account, so a stolen session cannot promote itself through the admin routes.
What is still your job
Section titled “What is still your job”- Terminate TLS in front of the application. The cookie is marked
Secure, so without HTTPS it will not be sent at all. This is a functional requirement, not a recommendation. - Configure your trusted proxy ranges honestly, as described in the operations contract. Getting this wrong breaks rate limiting in one direction or the other.
- Authorization. These bricks answer who someone is and which role they hold. Deciding what each role is allowed to do is your application’s logic. Keep that logic in one place.
- Anything beyond
SameSite=Laxthat your threat model calls for. The bricks define no state-changing GET requests of their own.
Found a vulnerability? Report it privately through the gouncer security policy.