How KinAuth is built
KinAuth holds the identities of an organization and decides who gets into its applications. This page is for the reader who wants the specifics behind “secure by design”: what the server is made of, and how each guarantee is kept.
What it is made of
- The server is written in Go, on its standard library and one module more: golang.org/x/crypto, the Go team’s own, with the one package of golang.org/x/sys it needs. Their source is kept in our repository.
- The panel has one dependency: Preact, two files copied into the repository and pinned by their hash.
- There is no npm and no bundler, and building it fetches nothing from a network.
- What it needs beyond that, we wrote: the PostgreSQL driver, SAML with its XML signatures, OpenID Connect with the token signing under it, the codes of an authenticator app, passkeys with the CBOR they are written in, and the QR encoder.
- The one thing we never write is a cryptographic primitive. Hashes, ciphers, signatures, key derivation and TLS are the standard library’s or x/crypto’s.
Its own PostgreSQL driver
The driver is ours and deliberately narrow.
- TLS always: TLS 1.3, with the server’s certificate and name verified. There is no option to turn it off.
- SCRAM-SHA-256 only, with the server’s proof verified. A database that asks for a cleartext or an MD5 password is refused before anything is sent, and so is one that lets the driver in without authenticating: it has not proven it is ours.
- One statement per query, with its values as bound parameters, by construction of the messages the driver sends. The one exception is the schema’s migrations, which are compiled into the binary.
Organizations kept apart, twice
Two layers, each sufficient alone.
- In PostgreSQL, every table that holds an organization’s data has row level security, enabled and forced, with one policy: the row belongs to the organization the current transaction was opened for. With none set the policy matches nothing, so a query that forgets its organization gets no rows instead of all of them.
- Foreign keys include the organization, so a row cannot refer to another organization’s row whatever ids the application supplies.
- In the application, a query on an organization’s data can only be written inside a transaction opened for that organization, which comes from the host name of the request and never from the request’s data. Every query filters on the organization as well.
- The server runs as a database role that owns nothing. When it starts, it checks that its role is not a superuser, cannot bypass row level security, cannot create roles or databases and owns no table, and that every table it can reach has row level security. If not, it does not start.
- Each organization is a host name of its own, and so a separate origin to a browser. The session cookie is bound to that host: presented to another organization’s address, the session does not exist.
Passwords and sessions
- Passwords are hashed with Argon2id, with the parameters of RFC 9106 (64 MiB, 3 passes, 4 lanes) and a random 128-bit salt. The minimum length is 12, with no composition rules.
- A sign-in does the same work and gives the same answer for an unknown address, a wrong password and a suspended account.
- Failed sign-ins are throttled. For an address typed, the fifth failure blocks it for 30 seconds, doubling up to 15 minutes; for a client address, across accounts, the twentieth blocks it for a minute, doubling.
- Sessions are kept on the server. The browser holds a random 256-bit token in a cookie that is HttpOnly, Secure, SameSite=Strict and __Host- prefixed; the database holds only its SHA-256. A session ends after 12 hours, or after 1 hour without use.
Keys and secrets
- The keys that sign tokens and SAML responses are stored sealed under a master key that is not in the database, with AES-256-GCM from the standard library. A copy of the database alone signs nothing.
- The server does not start without its master key.
- No secret is stored in the clear, but for the last four characters of a client secret, kept to tell two apart. One that only has to be checked is stored hashed; one that has to be used again, such as the key of an authenticator app, is stored sealed.
- A secret the server makes for someone to carry away, such as recovery codes or an application’s client secret, is shown once, when it is made.
Single sign-on
- OpenID Connect: the authorization code flow only. There is no implicit flow and no password grant. PKCE with S256 is required of a public client always, and of a confidential one unless an administrator turns it off for that application.
- ID tokens are signed with ES256 or RS256 and nothing else. A refresh token is spent by its use, and a spent one presented again revokes the whole family. Each organization is its own issuer.
- SAML 2.0: one identity provider per application, each signing with a key of its own, so a response for one application is not another’s. A response goes by HTTP-POST to an address registered for that application; nothing is sent to an address taken from a request.
- Access is asked again each time a token is issued: an active user, an active application, access by group.
Second factors
- Approval on a phone. The device creates a P-256 key and the server stores its public half: nothing in the database can approve a sign-in.
- A challenge carries a nonce and a two-digit number. The sign-in page shows the number; the device is offered three and is never told which is right, and approving with the wrong one is a denial. So a request that someone else caused cannot be approved by reflex. A challenge expires in 90 seconds and is answered once.
- Every request an enrolled device makes is signed: the method, the host, the path, the time and the hash of the body, each field length-prefixed so that no two requests share a message. The host is signed, so a request is valid for one organization, and its time must be within 60 seconds. The enrollment itself is signed over the host, its single-use ticket and the new key.
- Passkeys. The relying party is the organization’s own host, never a domain above it. A passkey is the second step after a password, or signs in alone when it verifies its user.
- Authenticator codes follow RFC 6238. Recovery codes are ten codes of 80 random bits, each good once and stored as a hash.
An append-only audit log
- Every sign-in, and every change to users, groups, devices, the organization’s settings, applications and their keys, is recorded with who, when, from which address and what.
- The entry is written in the same transaction as the change: both happen or neither does.
- The log is append-only: the database refuses an update or a delete from every role the server and its schema use.
- No entry holds a secret, a code or a challenge; of a client secret, only its last four characters.
The panel and the browser
- The panel is static files compiled into the server’s binary. It never sees the session, and it renders every value as text: no HTML is built from data anywhere.
- A Content Security Policy on every response allows only this origin’s own scripts and styles: no inline code, no eval, no framing. Trusted Types are required with no policy allowed, so assigning a string to innerHTML throws.
- A request to the API from another origin that would change state is refused. The protocol endpoints, which other sites must reach, read no cookie.
- The panel is not trusted. It hides what a user cannot do; the API is what refuses it.
Tests that try to break it
- The guarantees are held by tests that try to break them: reading and writing another organization’s rows, escaping row level security as the server’s own role, editing the audit log, a database that skips authentication, requests from another origin, markup in names.
- The tests run against a real PostgreSQL over TLS. A test that cannot reach its database fails; it does not skip.
- The protocols we wrote are tested against the vectors of their specifications where those give them (SCRAM, token signing, PKCE, authenticator codes), and against another implementation where they do not: xmlsec1 for SAML’s signatures, Chrome’s authenticator for passkeys.