Skip to content

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_.

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:

Terminal window
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.

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:

Terminal window
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.

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.

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.