Operations contract
Four things every deployment of an authkit application must get
right.
None of them fails at deploy time. Your deploy goes green and the problem appears later, as a security weakness or as users unable to log in. That is why they are collected here rather than left to be discovered.
Throughout, the application is called myapp and its environment
variables start with MYAPP_.
1. TLS in front is mandatory
Section titled “1. TLS in front is mandatory”The session cookie uses the __Host- prefix, and browsers only accept
such a cookie over HTTPS. Without HTTPS in front of your application,
nobody can log in at all, because the browser silently discards
the cookie.
Any TLS-terminating proxy will do. The application itself speaks plain HTTP on your internal network, so it needs no certificate of its own.
There is one exception: browsers accept secure cookies over plain HTTP
on localhost. Development and end-to-end runs therefore need no TLS
and no special insecure-cookie setting.
Have the proxy keep the visitor’s Host header and send
Strict-Transport-Security. The
cross-origin guard
relies on both when an older browser writes.
2. Tell the application which proxy to trust
Section titled “2. Tell the application which proxy to trust”The login rate limiter counts failed attempts per client IP. Behind a
proxy, every request arrives from the proxy’s own address, so the real
client address comes from the X-Forwarded-For header. Since anyone
can write that header, authkit/ratelimit 0.3.1 and later trust it
only from proxy addresses you name.
If you name none, or a range that misses the proxy’s address, every visitor looks like the proxy and they all share one budget. A handful of failed logins by one person then locks out login for everybody.
Set your proxy’s network range in the application config, which passes
it through ParseTrustedProxies into ratelimit.Config.TrustedProxies.
For a proxy on a shared container network, find the subnet with:
docker network inspect <proxy-network> \ -f '{{range .IPAM.Config}}{{.Subnet}}{{end}}'Make a missing value stop the deployment rather than degrade it quietly:
environment: MYAPP_TRUSTED_PROXIES: "${MYAPP_TRUSTED_PROXIES:?set to the proxy network subnet}"One caution: trusting a range means trusting everything running inside
it to report X-Forwarded-For honestly. Keep that network limited to
the proxy and the applications it fronts.
3. Create the first admin with the binary
Section titled “3. Create the first admin with the binary”A fresh database has no users, and creating a user requires being
logged in. A command of your own binary breaks that circle. With
gonsole it is
account:create-admin. It parses
the account flags, runs every step in Program.Migrations, and reads
the password as a single line from stdin. List
accountpg.Migration() from gonsole/auth/postgres there, so the
auth schema exists.
It needs no shell and no extra tooling, so it works even in a distroless image. A terminal shows the password as you type it, so pipe it in:
printf '%s\n' "$PASSWORD" | docker compose exec -T myapp \ /myapp account:create-admin \ -email maria.perez@example.com -name "Maria Perez" -role admin-T tells docker compose exec not to open a terminal, so the pipe
reaches the command. A program without gonsole gets the same command
from authkitpg.RunCreateAdmin, which migrates the auth schema
itself.
Never create that first account by inserting database rows by hand. The command validates the input, hashes the password properly, and makes sure the schema exists before it writes.
4. Run both migrators, library first
Section titled “4. Run both migrators, library first”authkit/postgres owns the auth schema and tracks which of its
migrations have run in its own auth.goose_db_version table. Your
application tracks its own in a separate table. Run both at every
start, the library’s first:
if err := authkitpg.Migrate(ctx, databaseURL); err != nil { return err}if err := appMigrate(ctx, databaseURL); err != nil { return err}Two sets of migrations sharing one tracking table will each misread the other’s entries as their own, and corrupt both histories.
Your own goose runner should also turn on goose’s session locker.
goose is the migration library authkit/postgres uses. The locker
takes a Postgres lock before a runner applies anything and frees it
after. So two runs that overlap, such as two servers starting at
once, never apply the same migration together. The second waits,
then finds nothing left to apply:
locker, err := lock.NewPostgresSessionLocker()if err != nil { return err}provider, err := goose.NewProvider(goose.DialectPostgres, db, migrations, goose.WithSessionLocker(locker))With gonsole, list both as steps in Program.Migrations, the
library’s first. serve never runs them, so run myapp migrate
before each release starts. It applies them in that order. The two
steps of gonsole/auth/postgres already turn the locker on.
accountpg.Migration() runs authkitpg.Migrate, which has turned it
on since authkit/postgres 0.11.2. accountpg.RecordMigration()
turns it on too.
Any other module that owns database tables follows the same rule with its own table. Plugins are the common case, and the host runs their migrations for you before any plugin starts.
Settings are documented in the code
Section titled “Settings are documented in the code”Session lifetime, cookie name, cleanup interval and the rate limit
budget are fields on authkit.Config, authkit.ReaperConfig and
ratelimit.Config. Their defaults and descriptions live with the
code, which is the only place that cannot drift.
Bind them to environment variables in your application, and give each
product its own cookie name following the __Host-myapp_session
shape.