How Gatehouse works
Every way in, what travels between the browser, Gatehouse and the identity provider, and what each step checks before a session exists. The diagrams are generated from text files kept beside the code, and a test fails when one goes stale.
Sign-in methods
A session records how it was established and whether that proved a second factor. The policy engine reads the second part as context.mfa, so a rule can ask for it without knowing which method was used.
| Method | Recorded as | Counts as a second factor | Session lasts |
|---|---|---|---|
| Email and password | password | No | 7 days |
| Password and authenticator code | password+totp | Yes | 7 days |
| Recovery code | recovery-code | Yes | 7 days |
| Passkey | passkey | Yes (the device and the person's unlock) | 7 days |
| Corporate SSO (SAML) | saml | Yes: the identity provider vouches for it | 8 hours |
| Google, Microsoft | google, microsoft | No | 7 days |
| Demo persona | demo | Yes, so the policy tester shows a hardened session | 2 hours |
Google and Microsoft sign-in are wired behind configuration and show not configured in this demo here: they need OAuth credentials this sample does not hold, and they have never run against the real providers.
Password and authenticator code
Passwords are 10 to 128 characters and stored as Argon2id hashes (19 MiB, two passes, one lane: the OWASP minimum, written into each hash so it can be raised later). A wrong password and an unknown address answer the same way. The second factor is a six-digit authenticator code, with ten single-use recovery codes for a lost phone. Sign-in is limited to eight attempts a minute per address, and so is each second-factor endpoint.
The session cookie is HttpOnly, SameSite=Lax and, in production, Secure with the __Secure- prefix. Addresses are never stored raw: sessions and audit events hold a keyed hash, and the interface shows its first eight characters.
Passkeys
A passkey replaces the password and the code at once. The browser asks the authenticator to sign a fresh challenge for this site's origin; Gatehouse checks the signature against the public key stored at registration. Nothing the server holds can be replayed somewhere else, which is why a passkey counts as multi-factor.
Registration is not verified against an attestation: any authenticator is accepted, as most consumer sites do. A site that has to know which hardware a key lives in would add that check.
SAML single sign-on
Sign-in is started from Gatehouse (service-provider initiated) over the HTTP-Redirect binding with a signed request, and answered over HTTP-POST. A response that does not answer a request Gatehouse made is refused, which also closes the IdP-initiated door.
The order of the checks matters: nothing the response says is trusted until the ledger has named the connection and the one certificate to verify it against. The flow inspector at /flow shows this exchange for real, message by message, including the decoded XML and each check. When a check fails the sign-in page shows one of these sentences:
| Code | What the person is told |
|---|---|
sso_unsolicited | The response does not answer a sign-in that this application started. IdP-initiated sign-in is not supported. |
sso_unknown_request | The response answers a request this application has no record of. |
sso_browser_mismatch | This sign-in was not started by this browser session (a response is only accepted by the browser that asked for it). |
sso_invalid_signature | Signature verification failed: the response was not signed by the certificate this connection trusts, or it was changed after it was signed. |
sso_issuer_mismatch | The response was issued by a different identity provider than this connection's. |
sso_audience_mismatch | The assertion was issued for a different service provider. |
sso_recipient_mismatch | The response was addressed to a different endpoint than this one. |
sso_outside_validity | The assertion is outside its validity window (too early, or expired). |
sso_replayed | This response was already used. Each response signs a person in once. |
sso_expired | The sign-in request expired before the response arrived. Start again. |
sso_attributes_invalid | The assertion does not carry a usable e-mail address. |
sso_provisioning_refused | The directory account could not be signed in to this workspace. |
Responses must be signed with RSA-SHA256 or RSA-SHA512; SHA-1 is refused. Assertions are signed but not encrypted, and single logout is not implemented: ending a Gatehouse session does not end the identity provider's, and the reverse.
Just-in-time provisioning
The first valid assertion for a person can create them. These are the rules, in the order they apply:
- A known identity signs in. The connection and the directory's NameID were linked before.
- A new identity may attach to an existing account only inside the workspace. If the email address already belongs to someone who is a member of this connection's workspace, the identity is linked to them. If it belongs to an account outside, the sign-in is refused (
email_taken). Otherwise a workspace admin who points a connection at an identity provider they control could assert someone else's address and walk into their account. - An unknown address creates a person only if the connection has just-in-time provisioning on. Otherwise it is refused (
jit_disabled). The address is marked verified only for the built-in directory: a real provider's claim about an address is not proof. - The role comes from the groups. The connection maps directory groups to roles; the highest rank among the person's groups wins, and a person with no mapped group gets the connection's default. The role and team are re-read from the directory on every sign-in.
- A deactivated membership is refused (
deactivated), no matter what the directory says.
The built-in identity provider
The demo's identity provider lives in the same codebase, under /idp. It signs real SAML 2.0 responses with its own key, so the whole round trip can be watched without an account anywhere else. Three fictional people (a financial analyst, a platform lead, a chief of staff) have different groups and so become a member, an admin and an owner. A checkbox on its sign-in page edits the groups after signing, so you can watch the signature check refuse the response.
It shares a process with the service provider, so it shows the protocol, not two organisations on two domains (the reasoning is ADR-0001 in the repository's docs/adr folder). To use a real one, see Connect Okta or Entra.
SCIM
Single sign-on says who someone is when they arrive. SCIM is how the directory tells Gatehouse who should exist at all, and, more importantly, when someone leaves. A deactivated person keeps their history but loses every session at once and cannot get a new one by any method.
The endpoints, the filter grammar, the errors and the rules are on the SCIM contract page.
Policies
What a signed-in person may do is decided by one function that every guarded page and route asks. A role matrix gives the baseline; rules written in a small expression language add or take away. A deny that fires always wins, and a fact that is missing counts against the person, not for them.
The language, the matrix and the rules every workspace starts with are on the policy language page, generated from the code.
Sessions and the audit log
Each person sees their own sessions (device, how they signed in, a hashed address prefix, last seen) and can revoke any one or all the others. Every sign-in, failure and security change, and every action a SCIM token takes, is written to an audit log that the database itself refuses to edit or delete: triggers reject updates, and rows leave only when a whole workspace does. Owners and admins read it under Audit.