A close-up portrait of a man with a salt-and-pepper beard wearing a white collared shirt against a textured beige background.
A close-up portrait of a man with a salt-and-pepper beard wearing a white collared shirt against a textured beige background.

Rolling out Google Identity Platform

Jesse James Richard
|
Jan 31, 2026
|
10 min read
#Access Control
#Architecture
The Google Cloud Identity Platform product icon.

Authentication was the first thing Giant Context needed and the first thing I decided not to write. Sign-in, password storage, resets, sessions, multi-factor, lockout rules and recovery flows all run on Google Identity Platform, and none of it is in my repository.

The last platform I worked on ran on Azure AD B2C. B2C is a hosted service, but the integration around it was custom, and custom enough that it was outsourced to a specialist. You can avoid writing an identity service and still end up with a bespoke system, by building enough scaffolding around someone else's that only the people who wrote the scaffolding understand it.

This is what a small integration looks like instead.

Inviting someone who does not exist yet

Keying a person to an email address rather than to a provider account changes what an invitation can be.

An invitation is its own row. An organization, an email, a role, a random token, an expiry. There is no user record and no membership behind it, because the person may not have an account anywhere yet. It is a statement about an address.

They sign up later through Identity Platform under that address, which creates their user row in Giant Context, and accepting the invitation compares the two emails before writing the membership. Sign up under a different address and the token will not resolve. Giant Context can decide what someone will be allowed to do before it knows anything about them, as long as the address matches.

The front door belongs to Google, and that is the price. If Identity Platform is unreachable then nobody signs in, and there is no local password to fall back to, because there are no local passwords anywhere. I would make the same trade again on a project this size, and it should be made deliberately.

What the rollout actually took

Two sign-in methods are enabled. Google, and email with a password. No Apple, no GitHub, no SAML, no magic links, no phone.

Identity Platform and Firebase Authentication are the same service with two consoles, which neither set of documentation makes obvious. Administering it through Identity Platform keeps it in the Google Cloud console with everything else the platform runs on, under one permissions model, with one place to look during an incident. The Firebase console never has to be opened.

The client side is four calls, and they are the entire authentication surface a person ever touches.

The whole client surface

await signInWithPopup(auth, new GoogleAuthProvider());await signInWithEmailAndPassword(auth, email, password);await sendPasswordResetEmail(auth, email);await firebaseSignOut(auth);

Everything around those calls is error handling and React state. There is no sign-in page in the router, either. The whole application sits inside a gate component, and when there is no signed-in user that gate renders the sign-in form instead of the route.

On the server, the part that touches the provider is four lines:

The entire provider integration

export const verifyIdToken = async (idToken: string) => {  const auth = getFirebaseAuth();  return auth.verifyIdToken(idToken);};

That one call does the signature check, the expiry and audience validation, and the signing-key rotation, with the keys cached in process. It is the only place in the codebase that knows the provider exists.

The middleware around it is longer, and almost all of the length is the next step. A verified token tells you who someone is to Google. It says nothing about who they are here. So the middleware reads the provider's id out of the token, looks up the matching row in my users table, and replaces it:

The line the whole design rests on

const decoded = await verifyIdToken(bearerToken);const user = await findUserByFirebaseUid(decoded.uid);if (!user) return unauthorized(reply, "USER_NOT_LINKED");
// uid is now the internal id, not the provider'srequest.user = { ...decoded, uid: user.id };

After that line, nothing downstream knows a provider exists. Handlers, queries, permission checks and audit records all work in my identifiers. The provider's id survives in one column on the users row, nullable and unique.

Nullable because a service account is a user row with no provider identity at all. Postgres allows any number of nulls under a unique constraint, so one column expresses both that every person has a distinct provider identity and that some accounts have none, without a second table or a flag.

The rest of that table is profile and preferences. It has no password, no hash, no salt, no session token, no multi-factor secret and no recovery codes. Grep the migrations for any of those words and nothing comes back. The application has never handled a credential, which is why a breach of that table is a list of email addresses rather than the end of the company.

A screenshot of a dark-themed software dashboard showing the settings page for a platform named Giant Context.
The organization settings page. Every authenticated screen in the console sits behind the same middleware.

What the provider does not do

Identity Platform answers one question, which is whether this person is who they claim to be. It has no opinion about what they are allowed to do, and it should not, because authorization is specific to a product in a way authentication never is.

So Giant Context owns which organizations a person belongs to, and what their role permits inside each one. Both are looked up on every request that needs them, from Giant Context's own tables, never from the token.

The common alternative is to put roles into the token as claims. It is faster and it is what most tutorials show. It also makes a token a snapshot, so removing someone from an organization leaves them holding the access their token already describes until it expires, which might be an hour. There is a window where the system disagrees with itself and nobody can see it.

Computing it fresh removes the window. Revoke access and the next request is refused. I built claims into the tokens at the start, pushing roles and permissions into them on every change, and then found that nothing read them, because every check was already going to the database. Removing them was deleting code that had never done anything.

One consequence surprises people. A request naming an organization you have no membership in does not return a permission error, it returns a not-found. A 403 would confirm the organization exists, which is information a stranger should not get from guessing a URL.

What the first week taught me

The documentation covers verifying a token. It does not cover any of this.

There is no seed and no first user. No migration inserts anybody. A user row is created the first time a verified token arrives without a matching row, on the endpoint that returns the current user. That worked until React's development mode double-mounted the component calling it, two requests raced, both found no user and both inserted. The second hit a unique violation, so the very first request a new person ever made returned a 500. An upsert fixes it in one line, and nothing about the symptom points at the cause.

The first administrator was typed into a database console. Only an administrator can promote an administrator, which is correct and leaves no way in. There is no bootstrap script and no environment allowlist, just an UPDATE statement run by hand, once. Every system with a global admin has this moment and nobody writes it down.

There is no emulator here, so local development signs in against the real pool. That has a daily cost. Iterating on the signup flow means deleting a real account by hand, from Identity Platform and from the database, in the right order, and there is a documented ritual for doing it. An emulator removes that, and I would run one on a project with more than one developer.

The one that took longest to understand was a dead session. When a token cannot be refreshed, a laptop waking from sleep, a network blip at the wrong moment, the request goes out without one and comes back 401. There was no handler for that, so it landed in the same branch as a network failure and the person saw a connection error with a retry countdown that could never succeed. The backend was fine. Their session was gone. Those two states look identical from the screen, and the only reason it was survivable was a sign-out button that happened to be sitting on that error page.

Handle the 401 separately from the network failure, and decide what a dead session should look like before you ship the error screen.

Wiring this up yourself

None of this requires building an identity service. The crossing needs two halves, and only one of them is work you own.

The rented half is the identity provider. On Google Cloud, enable Identity Platform, point your client at its sign-in SDK, and verify its tokens on your server with the provider's library. On Azure, the same role is played by AD B2C. Either way, this half is roughly an afternoon, and most of it is configuration.

The owned half is your own users table, which you were building anyway. Add one column for the provider's id. Resolve every verified token to your internal user id at the API boundary, and key memberships, grants and events to your id, never theirs. The walls and the roles are ordinary tables plus a check that runs on every request.

That is the whole split. The part that is dangerous to get wrong is somebody else's product. The part that is specific to your platform is a few queries against tables you own, and the seam between them is one column holding a provider id. The hard part is not the machinery. It is holding the line that authorization always comes from the schema and never rides the token.

No test, no endpoint

#Testing
#Architecture

An API endpoint with no test beside it does not exist, as far as the platform is concerned. The generator that builds the route tables looks for a sib...

Jesse James Richard

|

Jan 27, 2026
Read previous

Building something like this

I'm Jesse. I build platforms end to end, and I'm open to work. If this is the kind of engineering you need, get in touch.

Contact Jesse
Home
About
Contact
Sitemap
Privacy Policy
Terms of Service
Cookie Policy
Rolling out Google Identity Platform | Jesse James Richard