The trap, in one sentence
AWS Cognito user pools cannot export password hashes. You can export user attributes (emails, names, whatever you've stored), but the password material stays inside the pool, permanently. There is no API, no support ticket, no flag. If you ever need your users to exist somewhere other than that exact user pool, every one of them must set a new password.
This is a defensible security posture, and to be fair to AWS, it's documented. But it has a consequence that's easy to miss when you're picking an auth provider on day one: the user pool, not your database, owns your users. Your DynamoDB or Postgres rows are annotations on identities whose canonical home is a service you can't copy.
How we found out the hard way
Last year we began cleaning up our AWS estate: one aging shared account was becoming an AWS Organization with a separate account per product: separate blast radii, separate cost lines, clean ownership. Standard hygiene.
Then the migration reached FundraisingAccounts, our fundraising-tracking SaaS for booster clubs, which has been in production since 2019. Every resource it owns (tables, functions, buckets) could move to its new account as data or infrastructure-as-code. Except one: the Cognito user pool. Real users with real passwords, and no way to carry those hashes across. Our options were forcing a password reset on every user of a working production app, or leaving the identity provider behind in the old account indefinitely. The migration stalled on the IdP: the only piece of the entire estate that couldn't be lifted and shifted.
The pre-emptive move
RotoAlpha was on the same path: multiple environments, its own AWS account on the roadmap, and, critically, zero production users yet. The cost of leaving Cognito would never be lower. So we decommissioned it entirely and replaced it with a deliberately boring stack:
- Custom HS256 JWTs, issued and verified by our own code. The signing
secret lives in SSM Parameter Store, one per environment
(
/rotoalpha/{env}/auth-jwt-secret). No hosted UI, no managed pool. - Our own user IDs. Each user gets an internal ULID minted by us, and
the JWT
subclaim carries it directly. That ULID is the partition key for every user-scoped row in the system. - Users as ordinary rows. The profile row and a small credential table (for email → userId lookup and the password hash) are plain DynamoDB items. A user is fully portable across environments and AWS accounts with normal table tooling: the exact property the stuck migration was missing.
- A small authorizer Lambda that verifies
Authorization: Bearer <token>against the SSM secret. That's the whole perimeter.
We considered the middle path, a third-party managed provider like Auth0 or Clerk, and rejected it for the same reason we were leaving: it trades one black box for another. The export-and-portability constraint is structurally identical, plus per-MAU pricing on a pre-revenue product.
What it costs us
Honesty section. Rolling your own auth means owning things a managed IdP does for free, and pretending otherwise is how these posts mislead people:
- Correctness is ours. Token signing and verification, password hashing, session revocation, the authorizer: every one of those is our code and our pager. (We've already paid tuition: a one-line DynamoDB mistake in session revocation once turned password resets into a lockout loop. That's a future post.)
- Key rotation is ours. Nobody rotates the JWT secret for us.
- No free features. MFA, OAuth federation, hosted login pages, and advanced threat detection: if we want them, we build or integrate them.
For a small team, that trade is only sane because the surface is small and the alternative cost was concrete: we had a live demonstration one product over of what the trap looks like when it springs.
If you're choosing an IdP today
Two questions to ask before the first production user, because afterward the answers are expensive:
1. Can you export credentials? Not user attributes: the password hashes. If the answer is no (Cognito) or no-in-practice, the provider owns your users, and every future architecture decision inherits that constraint.
2. What does your account topology look like in three years? If there's any chance of splitting products into separate cloud accounts, merging after an acquisition, or changing providers, the IdP is the one component that won't move with you. Plan for it on day one or pay for it with a mass password reset later.
Cognito is still a reasonable choice when your account topology is settled, you want MFA and federation out of the box, and you'll accept forced resets as the price of a future migration. We chose control instead, because we'd already seen exactly what the alternative costs.
Where this leaves us
FundraisingAccounts is completing its move as part of a larger re-architecture.
RotoAlpha launches this season on auth it fully owns: users are rows, environments are
copies, and no future migration will ever stall on the identity provider. The last trace of
the old world is a cognitoSub field on old profile rows that nothing reads,
a small fossil from the world before this one.
More field notes from RotoAlpha's build, including the draft-strategy math and the backtests behind it, are coming as we approach launch at rotoalpha.com.