Joaquín BozzallaBlack-and-white engraving of a round vault door with a keyhole at its center

How we built our own “Sign in with Google”

Nov 6, 2026

Single sign-on (SSO) is one of the best inventions in user experience. It completely removes one of the biggest pains users have: remembering accounts and filling in passwords. The best example is “Sign in with Google”. One account to get into every Google service, and even into third-party services.

We wanted to build something like that for our client. A single authentication system for their main platform and for all the apps we’ve built and continue to build around it.

Here’s how the system we inherited was built, why it no longer let us grow, and how we solved it by building our own identity service with Ory Hydra and AWS.

What we inherited

We had a main platform and several satellite apps: a marketplace, an admin console and a few internal tools. They all shared the same users, stored in Amazon Cognito. Cognito is the AWS service for managing accounts, passwords and two-factor authentication (MFA).

The problem wasn’t Cognito. It was how we used it.

The main platform was the only app with a login screen. When a user signed in, it stored the tokens in a cookie shared across the whole domain. The satellite apps had no login; they just read that cookie. If the cookie was there, the user was signed in.

Over time this caused several problems:

  1. Every app had its own copy of the session code. It was copied from one app to another by hand, and there was even a comment in the code saying the sync was manual. Each copy had its own bugs.
  2. In some apps the session expired after an hour and in others it didn’t, depending on which copy of the code they had.
  3. Signing out logged you out everywhere, even on your phone. It was the only way to also sign out of the satellite apps.
  4. Every app had to live under the same domain to share the cookie.
  5. The cookies grew out of control. Every environment (development, testing and production) and every user who signed in on that browser added their own, and they were never removed. At some point the server rejected requests because the headers were too large.
  6. If the main platform went down, they all went down, because the satellite apps asked it for the user’s permissions on every request.

At one point someone tried to shrink the cookies and, through a domino effect across the apps, every user was signed out of every app in every environment. It was rolled back quickly, but it made it clear this wasn’t a one-off bug. It was a design problem.

What kept us from growing

Beyond the day-to-day problems, there were things we simply couldn’t do:

  • Adding apps without adding bugs. Every new app meant another hand-copied version of the session code, and every copy ended up with its own bugs.
  • Leaving the shared domain. We couldn’t have apps on other domains, or let third-party apps use our login.
  • There were no per-app permissions. To know whether someone could use an app, we looked up a role name in the main platform’s database and each app compared it as a string.
  • Users had nowhere to manage their account. To change their password they had to use “Forgot my password”.
  • We didn’t know who was signed in, from where, and in which app.

What we needed was an identity provider. A central service that authenticates the user once and gives the apps a signed token saying who they are and what they can access. The standard for this has existed for years: OpenID Connect (OIDC), built on top of OAuth 2.0.

The options

We considered three paths:

  1. Cognito’s managed login. It was the obvious choice, since we already used Cognito. The problem is that its session lasts a fixed hour and can’t be configured. After that hour, opening a new app asks for your password again, which defeats the purpose of SSO. It also doesn’t support remembered devices, so users would have to enter their MFA code every time they signed in.
  2. An external provider like Auth0 or Okta. They’re paid services and, on top of that, we would have had to migrate every user. We wanted the change to be invisible to users.
  3. Building our own. An identity provider built by us, with our own login screens and our own rules, still using Cognito behind the scenes to store users and passwords. It was the nerdiest option, but also the one that excited us the most.

We went with the third. That said, building an identity provider doesn’t mean writing OAuth from scratch. It’s a complex protocol, and any mistake ends up being a security hole. For that part we chose Ory Hydra, an open source, certified OAuth 2.0 and OpenID Connect server. Hydra handles the standard parts: issuing tokens, refreshing them, signing them and telling each app when a session ends. We chose it because it’s headless: it implements the protocol and nothing else. It doesn’t store users or have screens; it delegates login and consent to an app of ours through an API. That let us plug in Cognito as the directory without migrating anyone, and keep full control of the experience.

Before starting, we built a proof of concept in a separate AWS account. There we tested every scenario we had laid out. For example: signing into one app and then another without entering the password again, signing out of every app in that browser, and instantly blocking a disabled user. That confirmed it was the right path.

What we built

The architecture has three parts:

  • Hydra issues and signs the tokens. It’s the one that speaks OpenID Connect with the apps: it gives them tokens, refreshes them, publishes the keys to validate them, and notifies them when a session ends.
  • Our login app has all the screens. Hydra doesn’t show any screen; when it needs the user to identify themselves, it sends them to our app. That’s where login, MFA, “remember this device”, password reset and the “My account” page live. The rules live there too: whether the user can use that app, and which organization they’re working in.
  • Cognito stays as the user directory. It still stores users, passwords and MFA, but now only our login app talks to it. The apps no longer use it directly. This also saved us from migrating users.

Here’s how it all fits together on AWS. All traffic, from users and apps alike, comes in through CloudFront and goes through the WAF. From there it travels through a private origin to an internal load balancer, so the provider is never directly exposed to the internet. Each ECS task runs Hydra and the login app together, and in production there are between 2 and 4 tasks depending on load.

Traffic enters through CloudFront; the provider is never exposed directlyEach color is an AWS service categoryOur codeNetworkingSecurity and identityComputeDatabasesIntegrationMonitoringPrivate VPCUserbrowserAppsplatform, satellites and third-party appsRoute 53DNS and TLS certsCloudFrontCDN and TLSAWS WAFrules and limits per IP and appprivate originInternal load balancerALBECS Fargate· 2 to 4 tasks in productionHydraOAuth 2.0 and OIDCLogin appscreens and rulesAurora PostgreSQLHydra’s databaseand organizationsCognitodirectoryDynamoDBstate and limitsSSM + KMSencrypted secretsSESsending emailCloudWatchlogs, alarms and auditingLambdasecurity eventsEventBridgescheduled cleanupAll infrastructure is defined in Terraform, identical across the three environments.

An important piece is the token hook. Every time Hydra is about to issue or refresh a token, it asks our login app whether the user still has access. So if you remove someone’s access to an app or disable their account, it takes effect on the next refresh, within a few minutes.

Then we kept adding things that weren’t possible before:

  • Organizations. A user can belong to several and switch between them without signing in again. Similar to what Slack does with workspaces.
  • Per-app permissions within each organization.
  • Admin pages so each organization can manage its users.
  • Email invitations.
  • A “My account” page where each user changes their password and MFA and sees their open sessions.
  • An API so apps can do all of this from their own screens.
Before, every app read the same cookie; now they all trust one providerBEFOREUserMain platformwith the login screenShared cookieacross the whole domainSatellite app ASatellite app BSatellite app CCognitowhat can they access? on every requestNOWUserOur providerlogin + HydratokensMain platformSatellite app ASatellite app BNew or third-party appCognitodirectoryOrganizationsand per-app access

We also wrote an integration guide with reference code and packaged it as a Claude Code skill. Connecting a new app now means asking Claude to follow those steps, instead of copying code from another app.

Why didn’t we get rid of Cognito?

We could have replaced it, but we decided not to, for three reasons:

  1. Passwords can’t be exported from Cognito. Moving to another directory meant forcing everyone to reset their password, or running a gradual migration for months.
  2. Cognito was already doing its job well: storing passwords and MFA, and protecting against attacks. And we were already paying for it.
  3. By keeping it, the migration was invisible to users. Same user, same password, same MFA and same remembered devices.

The nice part is that, since only our login app talks to Cognito, the apps don’t know what’s behind it. If one day we want a system that’s 100% ours, the change happens in one place and no app has to be touched. In fact, today Cognito only stores passwords and MFA. Sessions, tokens, organizations, permissions, invitations and auditing are already ours.

The migration: the bridge

The most delicate part was the migration. We couldn’t move every app to the new system on the same day, so for a while apps on the old system and apps on the new one had to live side by side. We didn’t want users to have to sign in again when we made the switch, or to sign in twice to go from one app to another.

So we built a bridge between the old system and the new one, working in both directions:

  • If the user already had a session in the old system, the new one recognizes it (after validating it) and lets them through without asking for anything.
  • If the user signs in on the new system, they’re also signed into the apps still on the old one.
  • Signing out ends both, but only in that browser. It no longer signs you out on your phone.

Thanks to this we migrated the apps one at a time without users noticing any difference. The bridge was temporary, and we turned it off when the last app finished migrating.

How we did it and how we tested it

On day one we analyzed the system we had and decided on the architecture, and the next day the proof of concept was working. With that we set up the repository and the infrastructure for all three environments, and by day nine version 1.0 was in production. In the third week the first app started using it in production, and today, seven weeks after we started, we finished migrating all of them.

From decision to production, milestone by milestoneDays since we started, to scaleweek 1week 2week 3week 4week 5week 6week 7Analysis and architectureday 1Proof of conceptday 2Repo and infrastructureday 4Running in developmentday 7Version 1.0 in productionday 9First app migratedday 18All apps migratedday 46

Thanks to the bridge, users didn’t notice a thing.

Since this is the front door to every system, security was the priority. Every stage went through security reviews before reaching production.

Besides the proof of concept, we built two test harnesses of our own that we used throughout the project:

  1. An end-to-end and security harness. It’s a test app that connects to the provider exactly like a real app, plus an automated browser that drives it against the development environment. It walks through the normal flows (signing in, moving from one app to another, switching organizations, signing out) and also tries what an attacker would: reusing a token that was already used, keep signing in with a disabled account, or presenting a credential signed with a fake key. That way every change is tested the way a user experiences it, and the way someone from outside would try to break it.
  2. A performance harness. It measures each flow’s timings in development, even simulating a phone on 4G, and lets us compare before and after every optimization.

The performance harness helped us find several improvements. For example, the “My account” page went from 440 ms to 179 ms, logout from 148 ms to 45 ms, and the login screen on a phone on 4G went from 1.1 s to 0.7 s to render, with 37% less page weight. To load “My account”, the server went from 13 chained calls to 6.

How we built it using agents (Claude Code)

We wrote all the code with AI agents, using Claude Code. Since this service decides who gets into each app, we had to be more careful than usual. These are some practices that worked for us:

  1. Verify against the documentation, not against the agent. An agent can state something with great confidence and be wrong. Every fact we based a decision on, we confirmed in the official documentation before building on top of it. For example, that Cognito’s managed login session lasts a fixed hour, which is what made us rule that option out.
  2. Reviews with several agents. Before each pull request, several agents reviewed the change in parallel, each looking for something different: security, business rules, error handling, concurrency, performance, and whether the tests actually test what they claim to. Whatever they found was verified before fixing it.
  3. Nothing is done until it’s tested in a real environment. Unit tests aren’t enough. Every significant change was tested with the test harness against the development environment, with test users and a real browser.
  4. Clear limits for the agent. It could only use test accounts, only in development, and had to ask for explicit confirmation before changing anything. Production deploys are always approved by a person.
  5. Measure before and after. Every optimization was measured before and after applying it. When something got worse (it happened with font loading), we reverted it.
  6. Don’t make the agent guess. To connect a new app, the agent follows a skill with the steps and the reference code, instead of copying code from another app like we used to.

Wrapping up

In the end, we replaced the design, not the users. Cognito stayed as the directory, so everyone kept the same account, password and MFA. And thanks to the bridge, we migrated one app at a time without anyone noticing the change.

Now that the login is ours, adding passkeys, sign-in with each client’s identity provider, or third-party apps no longer means rebuilding anything. It’s building on what we already have.

We built this at Kelsus. If you’re thinking about doing something similar or want to talk about it, reach out: