Skip to content
All writing

· 7 min read

Moving every Farmdar app to Cognito without a big-bang cutover

How we replaced a home-grown JWT login with AWS Cognito across web and mobile apps in phases: dual-mode tokens behind a feature flag, a hosted UI, and a provisioning bug we only found in testing.

AWS CognitoOAuth 2.0GoMigrations

Where we started

Our Go backend issued its own HS256 JWTs and stored them in a cookie. It worked, but every new requirement landed on us: password resets, Google sign-in for staff, mobile apps, a partner API, and one account across products. Some of our users don't have an email address at all and sign in with a phone number.

We chose AWS Cognito. The hard part wasn't Cognito itself. It was moving users and apps that were in use every day without a night where everyone gets logged out.

Phase 1: accept both tokens, change nothing

The first release added Cognito next to the old auth instead of replacing it. The middleware looks at the token's header and routes it: HS256 tokens go down the legacy path, RS256 tokens are verified against Cognito's public keys (JWKS), which are cached and refreshed automatically, so there's no network call per request.

All of it sat behind a COGNITO_ENABLED flag that defaulted to false. Deploying the code changed nothing. We could ship it, watch it, and turn it on per environment when we were ready.

Phase 2 and 3: logins, refresh and mobile-only users

Next came login and refresh endpoints, still behind the flag. One detail cost us time: Cognito's refresh flow needs a SECRET_HASH computed from the user's Cognito username, and in our pool that is a UUID, not the email address. Using the email works for login and fails for refresh.

Then the unified login: it tries Cognito first, by email and then by phone number in E.164 format, and falls back to the legacy login if the user hasn't been migrated yet. Users moved over with an idempotent bulk script: email users get their email as the username, phone-only users get their phone number, and re-running it is safe.

On the frontend, an HTTP interceptor refreshes the token on a 401 and retries once. If several requests fail at the same moment, they wait for a single refresh instead of each starting their own.

Phase 4: hosted UI, and removing the old code

Once every app worked with Cognito tokens, we moved the tokens into HttpOnly cookies, added Google sign-in for staff, and switched to Cognito's hosted UI with the authorization-code flow. Then we deleted the legacy login, the HS256 path in the middleware and the feature flag itself. The migration isn't finished until the old path is gone.

The bug we found in testing

Creating users from our admin console was written database-first: insert the user, then sync to Cognito, and treat a sync failure as non-fatal. In our development environment every user created that way was half-made. The backend's IAM role had no Cognito permissions, every call returned AccessDenied, and the code swallowed the error. Admins saw a success message for users who could never sign in.

We rewrote it to create the identity first, with explicit rollback at every step:

  1. Validate the password against the pool's policy.
  2. Refuse to continue if Cognito isn't configured, instead of quietly skipping it.
  3. Create the Cognito user and set the password. If that fails, delete the Cognito user.
  4. Insert the user and their groups in one database transaction. If that fails, roll it back and delete the Cognito user.
  5. Only after the commit, do the steps that are safe to retry later, like adding the Cognito group.

Every failure now reaches the admin as a real error. The database can never point at an identity that doesn't exist.

Using the token for organisation access, carefully

Our backend decided which organisations a user could access from the database. Cognito tokens also carry the user's groups, and those are signed, so they can't be tampered with. We added a check that compares the two, but in log-only mode first, behind another flag. Mismatches get logged during a soak period before anything is enforced. The database still decides roles within an organisation; the token only proves membership.

What I'd do the same way again

  • Separate deploy from release. Every risky step shipped dark behind a flag.
  • Run old and new side by side, then delete the old path. Dual-mode is a bridge, not a destination.
  • Log before you enforce. A soak period with mismatch logging is cheap and catches surprises.
  • Never treat identity errors as non-fatal. A swallowed error in user provisioning becomes a support ticket weeks later.