Authentication is only the first part of deciding who may use an app
What an application still has to decide after Google or GitHub has verified someone: who is admitted, how fast a removal takes effect, what an invite may do, and what happens when an app changes hands.

On this page
Most of the code in a sign-in flow exists to establish one fact: this browser belongs to someone who controls a particular Google or GitHub account. Getting there takes an authorization code, a PKCE verifier, a state value, a token exchange and, with Google, a signature check, and because that is where the visible difficulty sits, it is easy to treat a successful callback as the end of the question. It answers very little of what the owner of an application wants to know. Google cannot tell you whether this person should see your team’s data, whether they should still see it tomorrow, or what should happen to a session that was perfectly valid when they left the company.
Ferrite, the application platform I build on and operate, makes this hard to avoid. The gateway in the upstream core that our sign-in package targets has no concept of authentication, so nothing turns away an anonymous request before application code runs, and all of the policy has to live in the application. The package, which I developed with agents at the end of September 2026 and which you install with ferrite add auth, is vendored source that holds no authority of its own: at run time it uses the store handle, the egress handle and the secrets of the application that contains it. That makes it a useful place to look at what an application has to own once the provider has done its part. I will use a small tool for a team of ten as the example.
Who has arrived
What comes back from the provider becomes a subject, github:<numeric id> or google:<sub>. It is never built from a login name or an email address, because both can be released and later claimed by somebody else. The verified address is still useful, so when the provider vouches for one, which for GitHub means the primary address it marks verified and for Google means email_verified is true, it travels on the session as an attribute for the application to match against its own records. At this point the team’s tool knows who has arrived and has decided nothing.
Who gets in
Deciding is the job of the access list, auth_allow, and the first thing to say about it is that it is required. An entry can be @domain for a provider-verified address at that domain, name@domain, github:<id>, google:<sub>, or * for anyone with an account, so an application meant for the public has to say so and cannot arrive there by leaving a setting blank. The list can name addresses even though the session’s key cannot, because a verified address at your company’s domain is often exactly what you mean, but there it is a policy you chose and not the identity everything else hangs on. Someone the list does not admit gets no session and a 403 page naming the account they used, which is the page you want when the real problem is that they picked their personal Google account by accident.
People named in auth_admins can add and remove entries on a page inside the application, with no redeploy. Those additions live in the application’s store and cannot include *, so opening the tool to the world stays a deployment decision, and administrators are always admitted, so a bad edit cannot lock them out.
The decision that does the most work is when the list gets judged. An application that checks it only at the callback has effectively written the answer into the session, and removing someone then means waiting for the session to expire or finding and deleting it. Sessions here last up to twelve hours, and the list is judged on every request, with the stored half cached for at most five seconds. If one of the ten leaves on a Tuesday afternoon and an administrator removes their entry, their next request is refused while their session record is still intact. The session says this browser is still the person who signed in, the list says whether that person is welcome now, and the two run on separate clocks.
The session has a lifecycle of its own. Each sign-in mints a new identifier and revokes whatever session the browser already held, and the record is stored under a SHA-256 of the identifier, so a dump of the store yields no usable cookie. Sign-out deletes the record, and signing out everywhere bumps a generation number on the subject, which stales every outstanding session without anyone having to enumerate them. The twelve-hour expiry is absolute, and that number matters because the provider is not asked again after the callback: GitHub’s access token, for instance, is used for its initial reads and dropped. Expiry is what bounds how long the application goes on trusting a sign-in after something has changed at the provider’s end.
None of this reaches a handler that runs before the check, which is the limit of doing this job in a package and not in a gateway. The gate is a function the application calls. The example application shows a request loop that makes gating the default, but the package cannot stop an application from answering a request before that call.
The person you have not met
Now a contractor joins for a fortnight. You do not know which account they will use and you do not want to admit their whole domain. An administrator creates an invite with a label, between 1 and 1000 uses and a lifetime between an hour and 90 days. The code and its link are shown once, and the store keeps only the code’s SHA-256.
The property I care about most is that an invite is not a way of signing in. The contractor still goes through the provider, and only then, if they are not on the list and arrived through the link, are they added to the stored half by their verified address, or by their account where the provider vouched for none. The remaining uses drop by one, the redemption is logged with the invite’s id and label and the person’s opaque subject, and the invite records who it admitted. So a link forwarded to the wrong person spends a use and leaves an identity on your list that an administrator can remove like any other. A used-up, expired or revoked invite admits nobody, and the page says why.
Local development is not a fallback
Local development is where arrangements like this tend to get undermined, because nobody wants to register an OAuth client to test a form, and the convenient fix is a bypass that sits one bad setting away from production. The package’s development sign-in is a separate, labelled page that asks which address to sign in as and contacts no provider. It works only on a local plain-HTTP fleet with no OAuth client, only when auth_dev_mode and auth_dev_sign_in are both set to their exact values, and on any https:// origin it is a configuration error, so it cannot be enabled on a deployed application.
It is also not what happens when a provider is missing. A deployment refuses while a secret the package reads is absent, unless it is explicitly told otherwise, and until that is sorted out gated pages answer with a 503 naming the first missing secret.
What a fake provider cannot tell you
The package’s tests pass offline, with every provider call answered by a double. The fake Google signs its ID tokens with a throwaway RSA key and publishes the public half, so the verifier really does run RS256 as it would against Google. That says a lot about the package’s own logic. It says nothing about a real client registration, real callbacks or what a given browser does with the cookies, whose attributes are asserted on the wire form, so a live sign-in is a claim a deployment has to make for itself.
Real use keeps finding things in that gap. In a rehearsal on a fresh installation, an optional sign-in secret turned out to be empty, and only a real Google sign-in and session reload on that installation could say whether sign-in worked there. A day earlier I had opened one of our sample apps, met a login screen I did not think should be there, signed in with Google and landed on a dialog whose Sign in button reloaded to itself. Both turned up on a deployed installation, which is exactly where an offline suite with a fake provider never goes.
When the app changes hands
The gap I find most instructive sits outside the package, in the platform’s decision about who may change an app and not in an application’s decision about who may use it, and it has the same shape. Application ids on Ferrite have the form <account>/<name>, and an app can begin under an ephemeral account and be claimed later by a durable one. On a reminders sample, that claim returned success and the next source commit was refused, because the app name being written to still carried the former ephemeral owner’s prefix. According to the claim the durable account now owned the app, and it still could not commit to it. We ruled out the obvious workaround, creating another app, which would have made the symptom go away and left the contract of same app, new owner, untested. The refusal came from the core, so it went to the core’s owners in a report of its own.
A fresh ephemeral account later got all the way through: deploy, persist data, claim through the dashboard’s API, authenticate a new owner credential, commit, remote test and redeploy as generation 2 with the original data intact. That shows a fresh owner can be attached, which is a narrower thing than the case that was refused, a merge into an existing durable account.
So after the provider has done its part, an application still has to decide who is admitted and under what kind of name, how quickly a removal takes effect, what a session is allowed to outlive, how to let in someone it has never seen and how to keep its local shortcut out of production. Most of those decisions only become visible on the request after something changed: the one after a person was removed, after an invite ran out, after the app changed hands. Those are the requests I would test first, because the callback is going to be green in every one of those cases.
Chris Chabot, November 2026