Migrating Off Amazon Cognito To Passwordless With Seamless Auth
This guide moves an application’s users from an Amazon Cognito user pool to Seamless Auth, an open
source (Apache-2.0) passwordless authentication server you run yourself, with passkeys (WebAuthn),
magic links, and one-time codes. It covers exporting users from Cognito, converting the export into
the CSV that seamless migrate imports, keeping Cognito sign-in working while users move, getting
everyone onto a passkey, and switching Cognito off. It also lists what Cognito does that Seamless
Auth does not, so you can decide whether the move fits before starting.
It is written for two readers:
- A developer or coding agent doing the migration. The commands, field mappings, and config
below are exact. The Seamless Auth parts were checked against the
seamless-cliandseamless-auth-apisource, and the export loop andjqconversion were run against samplelist-usersoutput. - A technical lead deciding whether to move. Typically at a software company whose government or regulated customers now ask for phishing-resistant MFA, or one that has outgrown Cognito. Start with What Changes and What You Give Up. If your application depends on Cognito identity pools for AWS credentials, read that row first.
This page is the Cognito-specific path through the general Migrate Users From Another Identity System guide, which has the full reference for the import report, rejection reasons, and enrollment APIs.
What Changes
Section titled “What Changes”Seamless Auth has no password sign-in, so there are no passwords to migrate. A migration off Cognito is two things:
- An identity import. Each user’s email, Cognito
sub, and optionally phone, groups (as roles), and a tenant or team attribute (as organization membership) become Seamless Auth accounts. No credential of any kind is imported: no password, no MFA secret, no passkey, no session. - A passkey enrollment campaign. Each imported user proves control of their email once, then enrolls a passkey. You track coverage and invite whoever has not enrolled.
That fits Cognito well, because Cognito has no way to export password hashes. Cognito accepts password hashes on import, but the user record that its APIs return carries no password field, and AWS’s own user profile export guidance states that it does not export user passwords and that users must reset them in the new pool (see Guidance components: Passwords). A migration to another password system means a forced reset for everyone or a just-in-time migration that keeps Cognito running. Moving to passkeys means neither.
TOTP secrets, SMS MFA settings, and passkeys that users registered with Cognito are not carried across either. Every user enrolls a new passkey with Seamless Auth.
Cognito user pools offer passkey and email or SMS code sign-in themselves, in every feature plan
except Lite. If your only requirement is offering passkeys while staying on Cognito, that may be
enough. The difference that matters for a phishing-resistance requirement is enforcement: in
Cognito’s sign-in policy, the passkey option must be accompanied by at least one other first factor
(Authentication flows: passkeys),
while Seamless Auth’s phishing_resistant_only accepts only a passkey to start a session.
Versions
Section titled “Versions”Everything on this page is released:
| Capability | Needs |
|---|---|
seamless migrate csv and bulk import (POST /admin/users/import) |
seamless-cli 0.18.0, seamless-auth-api 0.16.0 |
| Sign in through Cognito as an OpenID Connect provider, linked by id | seamless-auth-api 0.16.0 |
| Enrollment prompts, directory, invites, coverage report, provider retirement | seamless-auth-api 0.17.0 |
seamless config set prompt_passkey_enrollment |
seamless-cli 0.21.0 |
The full version table for the dashboard and server adapters is in
What Each Step Needs. A project scaffolded by an older
seamless init may pin an older auth API image; check it before migrating.
Step 1: Export Users From Cognito
Section titled “Step 1: Export Users From Cognito”Cognito has no bulk export job. The export is
ListUsers,
paged, through the AWS CLI’s aws cognito-idp list-users. It needs IAM credentials with
cognito-idp:ListUsers on the pool, plus cognito-idp:ListGroups and
cognito-idp:ListUsersInGroup if you export groups.
What to know before running it:
- Page size.
ListUsersreturns at most 60 users per call. The AWS CLI pages automatically, so--page-size 60makes each underlying call as large as it can be. - Rate limit.
ListUsersandListUsersInGroupshare theUserListrequest rate quota, 30 requests per second per account per Region by default, and it is not adjustable (Quotas in Amazon Cognito). The CLI makes one call at a time, so an export stays well under it, but the quota is shared with anything else in the account calling those operations, including your application. - What comes back. Each user has
Username,Attributes(an array ofName/Valuepairs, includingsub,email,email_verified,phone_number, andcustom:attributes),Enabled,UserStatus, and timestamps.ListUsersis eventually consistent, and the order can differ between runs, but one chain of pages does not return duplicates.
For a small pool, one command is enough, since the CLI follows every page itself:
POOL_ID=us-east-1_EXAMPLEaws cognito-idp list-users --user-pool-id "$POOL_ID" --page-size 60 \ | jq '.Users' > cognito-users.jsonFor a large pool, export in chunks so a failure part way through can resume from the last token
instead of starting over. With --max-items, the CLI stops after that many users and prints a
NextToken, which --starting-token resumes from:
POOL_ID=us-east-1_EXAMPLE
mkdir -p cognito-exporttoken=""page=0while :; do args=() [ -n "$token" ] && args=(--starting-token "$token") aws cognito-idp list-users --user-pool-id "$POOL_ID" \ --page-size 60 --max-items 1000 "${args[@]}" \ > "cognito-export/users-$page.json" token=$(jq -r '.NextToken // empty' "cognito-export/users-$page.json") echo "chunk $page written; next token: ${token:-none}" >&2 page=$((page + 1)) [ -z "$token" ] && breakdone
jq -s '[.[].Users[]]' cognito-export/users-*.json > cognito-users.jsonThe loop runs in both bash and zsh. To resume after a failure, set token and page to the last
values it printed and run the loop again.
The fields that matter, from
UserType
and Working with user attributes:
| Cognito field | Use |
|---|---|
sub attribute |
The external id. Unique in the pool and fixed for the life of the user, unlike email and phone_number, which users can change |
email attribute |
Required by the import |
phone_number attribute |
Already in E.164 form (+15555550101), which the import accepts |
Enabled |
false for a disabled user. Skip them rather than recreating them |
UserStatus |
UNCONFIRMED means the user signed up and never confirmed. EXTERNAL_PROVIDER means a profile Cognito created for a federated sign-in |
custom: attributes |
Where many pools keep a tenant, customer, or team id |
Use sub, not Username or email, as the external id. sub is what Cognito puts in the sub
claim of its ID tokens, which is how the cutover in Step 4 finds each imported user, and Cognito’s
documentation calls it the only consistent indicator of a user’s identity. Username takes a
different form depending on how the pool and the user were set up, and users can change their own
email.
Users who signed in through a federated identity provider (Google, a SAML or OIDC IdP) have
their own profile in the pool, with a username of the form <ProviderName>_<id> such as
Google_104857600123456789012, status EXTERNAL_PROVIDER, and their own sub
(third-party sign-in).
They export and import like any other user. A federated identity that you linked to a local user
with AdminLinkProviderForUser signs in to that local profile, so the local profile’s sub is
the one to import for that person
(Linking federated users).
Groups. ListUsers does not return group membership. If you use Cognito groups as roles,
list each group’s members with
ListUsersInGroup
and build a map from username to groups. Cognito creates a group for every federated identity
provider, named <user pool id>_<provider name>; those are not roles, so the command skips them:
aws cognito-idp list-groups --user-pool-id "$POOL_ID" \ --query 'Groups[].GroupName' --output text | tr '\t' '\n' \ | grep -v "^${POOL_ID}_" \ | while IFS= read -r group; do aws cognito-idp list-users-in-group --user-pool-id "$POOL_ID" \ --group-name "$group" --page-size 60 \ | jq --arg group "$group" '{group: $group, users: [.Users[].Username]}' done \ | jq -s '[.[] | .group as $g | .users[] | {user: ., group: $g}] | group_by(.user) | map({(.[0].user): map(.group)}) | add // {}' \ > cognito-groups.jsonThe result maps each username to its groups, for example
{"ada": ["staff"], "Google_104857600123456789012": ["staff", "manager"]}. If you do not use
groups, write {} to cognito-groups.json.
Step 2: Convert The Export To The Import CSV
Section titled “Step 2: Convert The Export To The Import CSV”seamless migrate csv reads a CSV with up to five fields. With these exact headers it needs no
mapping file:
externalId,email,phone,roles,organizationsemailis required.externalIdis the Cognitosub.rolesandorganizationstake several values separated by;. Organizations are referenced by slug or id and must already exist, and roles must be in the instance’savailable_roles. The import never grantsadminor anadmin:*role.- A role name is letters, digits, and hyphens, optionally in
:-separated segments. A Cognito group name with an underscore or a space fails validation and marks the row invalid in the plan, so rename those roles incognito-groups.jsonfirst. - Every other column is ignored.
First, find what the import cannot take as-is. A user with no email (a pool that signs in by phone
number, for example) cannot be imported, and two Cognito users with the same email (a local user
and a federated Google_ profile, or two users in a pool with email as an alias rather than the
username) become one Seamless Auth account, so the import rejects the second row:
# Users with no emailjq -r '.[] | select(all((.Attributes // [])[]; .Name != "email")) | .Username' cognito-users.json
# Emails held by more than one Cognito user (compared case-insensitively, as the import does)jq -r 'map({username: .Username, email: ((.Attributes // [])[] | select(.Name == "email") | .Value)}) | group_by(.email | ascii_downcase) | map(select(length > 1)) | .[][] | [.email, .username] | @tsv' cognito-users.jsonFor each duplicate, keep the row whose sub the person actually signs in with, since that is the
id the cutover links on. Then convert, skipping disabled users, users who never confirmed their
sign-up, and users without an email:
jq -r --slurpfile groups cognito-groups.json ' ["externalId", "email", "phone", "roles", "organizations"], (.[] | ((.Attributes // []) | map({(.Name): .Value}) | add // {}) as $a | select($a.email != null and .Enabled != false and .UserStatus != "UNCONFIRMED") | [ $a.sub, $a.email, ($a.phone_number // ""), (($groups[0][.Username] // []) | join(";")), ($a["custom:tenant"] // "") ]) | @csv' cognito-users.json > users.csvChange custom:tenant to wherever your pool keeps team or customer membership, or replace it with
"" to leave organizations out. Remove the UNCONFIRMED condition if you want to import abandoned
sign-ups too; they are harmless, since every imported user still has to prove their email. The
conversion avoids from_entries, because older jq releases do not read the Name/Value keys
Cognito uses. For three sample users (a local user, a federated Google user, and a user with a
phone and a custom:tenant) the output is:
"externalId","email","phone","roles","organizations""11111111-aaaa-4bbb-8ccc-000000000001","ada@example.com","","staff","""11111111-aaaa-4bbb-8ccc-000000000002","grace@example.com","","staff;manager","""11111111-aaaa-4bbb-8ccc-000000000003","lin@example.com","+15555550101","staff","platform"The CLI reads quoted RFC 4180 CSV, so @csv output is accepted as-is.
Step 3: Import
Section titled “Step 3: Import”Sign in to the target instance as an admin, create any roles and organizations the CSV refers to,
then dry run. Use --source cognito on every run, because external ids are matched within a source
and the cutover provider below refers to it:
seamless profile login productionseamless org create "Platform" --slug platformseamless migrate csv users.csv --source cognitoThe dry run writes nothing but sends every valid row to the instance with dryRun set, so the plan
reflects real data. It writes users.migrate-plan.csv and .json, with a status per row
(created, updated, unchanged, rejected, invalid) and a reason for each problem row, and
exits 1 if any row is rejected or invalid. Fix the data and repeat until the plan is right, then:
seamless migrate csv users.csv --source cognito --applyKeep users.migrate-result.csv: its userId column maps each Cognito sub to a Seamless Auth
account. Re-running is safe; a row matches on its external id first, then on email, and only ever
adds roles and memberships. Rejection reasons and the rest of the report are described in
Dry Run And Read The Plan.
An imported user is unverified until they prove control of the email, either by registering with it (an email code) or by signing in through Cognito as set up in the next step.
Step 4: Run Both During Cutover
Section titled “Step 4: Run Both During Cutover”You can let users keep signing in with Cognito while they move. Seamless Auth treats the user pool
as an OpenID Connect provider: the user signs in on Cognito’s managed login (with a password, or
through whatever federated provider they used before), Seamless Auth verifies the ID token, and the
token’s sub claim links the sign-in to the account you imported. Cognito documents sub as the
user’s unique identifier and the best way to identify a user
(Understanding the ID token).
The link is by id, not by email.
-
Give the user pool a domain, if it does not have one. The authorize, token, and userInfo endpoints only exist on a user pool domain, either a prefix domain such as
mydomain.auth.us-east-1.amazoncognito.comor a custom domain (user pool endpoints). -
Create an app client with a client secret for Seamless Auth (a traditional web application client). Add your Seamless Auth OAuth callback URL (for example
https://app.example.com/oauth/callback) to its allowed callback URLs, allow the authorization code grant and theopenidandemailscopes, and enable every identity provider your users sign in with: the user pool directory itself, plus any social, SAML, or OIDC providers. Keep read access toemailandemail_verifiedfor this client; Cognito rejects the token request when a client asks for theemailscope but cannot reademail_verified(token endpoint). Seamless Auth sends the client secret in the request body, which Cognito accepts (client_secret_post), and adds a PKCE challenge and a nonce, both of which Cognito supports. -
Set the client secret in the auth API’s environment, for example
COGNITO_CLIENT_SECRET. The provider config names the variable; the secret never goes in config. -
Add Cognito as a provider. Copy
issuerandjwks_uriexactly from the pool’s discovery document,https://cognito-idp.<region>.amazonaws.com/<user-pool-id>/.well-known/openid-configuration. The issuer is usuallyhttps://cognito-idp.<region>.amazonaws.com/<user-pool-id>, with no trailing slash, but a pool on Cognito’s updated issuer useshttps://issuer-cognito-idp.<region>.amazonaws.com/<user-pool-id>(Cognito as an OIDC issuer), and the value must match the token’sissclaim exactly. ID tokens are signed with RS256, which Seamless Auth accepts; it rejects symmetrically signed tokens. Save ascognito-provider.json:{"id": "cognito","name": "Your existing account","clientId": "<app-client-id>","clientSecretEnv": "COGNITO_CLIENT_SECRET","authorizationUrl": "https://<your-user-pool-domain>/oauth2/authorize","tokenUrl": "https://<your-user-pool-domain>/oauth2/token","userInfoUrl": "https://<your-user-pool-domain>/oauth2/userInfo","issuer": "https://cognito-idp.<region>.amazonaws.com/<user-pool-id>","jwksUri": "https://cognito-idp.<region>.amazonaws.com/<user-pool-id>/.well-known/jwks.json","scopes": ["openid", "email"],"redirectUris": ["https://app.example.com/oauth/callback"],"externalIdSource": "cognito","externalIdJsonPath": "sub","allowSignup": false,"accountLinking": "disabled","promptPasskeyEnrollment": true}Terminal window seamless config oauth-providers add --file cognito-provider.jsonexternalIdSourcemust equal the--sourceyou imported with, andexternalIdJsonPath: "sub"reads the Cognitosubfrom the verified ID token.allowSignup: falseandaccountLinking: "disabled"mean Cognito can only sign in accounts you imported. It cannot create new accounts or attach itself to other accounts by email.promptPasskeyEnrollment: truemakes the callback answernextStep: 'enroll_passkey'for a user without a passkey.AuthRoutesin@seamless-auth/react0.14.0 and later sends them straight to enrollment.
oauthmust be in the instance’s login methods. See How To Add OAuth Login for that and for rendering the provider button.
The first Cognito sign-in by an imported user marks the account claimed and links the Cognito identity. The session is a full Seamless Auth session; Cognito is only proving who the user is.
Step 5: Invite And Track Enrollment
Section titled “Step 5: Invite And Track Enrollment”Users who do not sign in through Cognito register with their imported email: an email code proves the address, then they enroll a passkey. To send code and magic link sign-ins without a passkey straight to enrollment:
seamless config set prompt_passkey_enrollment trueThen track and chase coverage:
GET /admin/enrollmentlists users with their passkey status (none,one,two_or_more), filterable by organization and byimported=true. The admin dashboard shows it on the Passkey enrollment screen.POST /admin/enrollment/invitesemails unenrolled users a notice to sign in and add a passkey. The email links to your sign-in page and carries no credential. Repeat calls skip anyone invited in the last 24 hours.GET /admin/reports/authentication-coveragereports passkey coverage overall, per organization, and over time, withformat=csvfor a spreadsheet. This is the figure to hand a customer’s security questionnaire.
Encourage two passkeys per person, so a lost device is not a lockout. Request bodies and filters are in Get Users Onto Passkeys.
Step 6: Turn Off Cognito
Section titled “Step 6: Turn Off Cognito”Retire Cognito sign-in per organization once its members are enrolled, or all at once.
- Per organization:
PUT /admin/organizations/:organizationId/oauth-providers/cognito/retirementrefuses Cognito sign-in for that organization’s members and revokes their live sessions, so they sign in again with a passkey or another enabled method.DELETEon the same path rolls it back. Details are in Sign In Through The Old Provider. - Everyone: disable the provider with
seamless config oauth-providers update cognito '{"enabled": false}', or remove it withseamless config oauth-providers remove cognito. This does not revoke sessions.
Check the enrollment directory first. A user with no passkey needs another enabled method, such as an email code, to get back in.
When coverage is where you need it, phishing_resistant_only: true accepts only passkeys to start a
session. It also switches off every OAuth provider, so Cognito sign-in must already be retired. See
Finishing The Migration.
After that, nothing calls the user pool for sign-in. Before deleting it, check for the AWS integrations in the next section that still read Cognito tokens, delete the Seamless Auth app client, and keep the export and the import result report as the record of the migration.
What You Give Up Or Must Plan For
Section titled “What You Give Up Or Must Plan For”Seamless Auth is narrower than Cognito, and Cognito is wired into AWS in ways an external auth server is not. Check this list against what your application uses before you start.
| Cognito has | Seamless Auth today |
|---|---|
| Identity pools that exchange a sign-in for temporary AWS credentials | Not provided. Seamless Auth does not issue AWS credentials. If a browser or mobile app calls S3, DynamoDB, IoT, or other AWS services directly with identity pool credentials, move those calls behind your backend, which uses its own IAM role |
Groups mapped to IAM roles (cognito:roles, cognito:preferred_role claims) |
Roles are application roles only, with no IAM mapping. Group membership imports as roles (Step 1); the IAM side has to be rebuilt in your backend |
| Native AWS integrations: API Gateway Cognito authorizers, Application Load Balancer authentication, and Amplify Auth | None of these read Seamless Auth sessions. Verify sessions in your backend with a server adapter instead, and replace any Cognito authorizer before turning the pool off |
| Lambda triggers (pre sign-up, pre token generation, custom auth challenges, user migration) | No equivalent. Put that logic in your own backend, behind the server adapter |
| Password sign-in | None, by design. Passkeys, magic links, email and SMS codes, TOTP for step-up, and OAuth providers |
| SAML and OIDC identity providers per user pool, including IdP-initiated SAML | No SAML. OpenID Connect and OAuth providers only, configured instance-wide rather than per customer organization. A customer that requires SAML SSO into your app is not served today |
| Managed login, a hosted sign-in page on your user pool domain | Sign-in renders in your app, with AuthRoutes from @seamless-auth/react or your own UI. See How To Build Your Own Auth UI |
| Acting as an OAuth 2.0 authorization server, with resource servers, custom scopes, and machine-to-machine client credentials tokens | Not provided. Seamless Auth signs in users of your application; it does not issue tokens to third-party apps or services |
| Coverage under AWS compliance programs, as an AWS service | None for Seamless Auth itself. Self-hosted, it runs inside your own boundary (which can be your AWS account) and is covered by your own controls and audits. Fells Code publishes its compliance status |
| A service AWS operates, with no servers to run | You run it (or use the managed option). You own patching, Postgres, signing keys, and email and SMS delivery |
What Seamless Auth adds for this kind of buyer: passkey-only enforcement (phishing_resistant_only),
a passkey coverage report you can export, organizations and memberships, and an append-only,
hash-chained audit trail of auth events (see Auth Events and Auditing).
Do users have to reset a password? No. There is no password. Each user proves their email once, or signs in through Cognito during cutover, and enrolls a passkey.
Can we get password hashes out of Cognito? No, and you do not need them. Cognito does not export passwords, and the import has no field for them.
Can a user who signed in to Cognito with Google still sign in?
During cutover, yes: they sign in through Cognito’s managed login as before, and the sub of their
Google_ profile links them. After cutover they use a passkey, an email code or link, or a Google
provider configured directly in Seamless Auth.
What happens to users with no email? The import requires an email. Users of a pool that signs in by phone need an email added in Cognito before export, or they sign up again by phone if phone codes are enabled.
Does our frontend’s direct access to AWS services keep working? Not if it uses Cognito identity pool credentials. Seamless Auth does not vend AWS credentials; route those calls through your backend.
Can we run the import more than once?
Yes. Rows match on the Cognito sub within source cognito, then on email, so re-running after
fixing data updates rather than duplicates. Access is only ever added.
What is the license, and does it affect our code?
Seamless Auth (seamless-auth-api, the SDKs, the CLI, and the admin dashboard) is Apache-2.0. It is
a permissive license: you can use, modify, and self-host it commercially, and it places no license
obligations on your application code. Keep the license and notices if you redistribute Seamless Auth
itself.
The license is backed by the Seamless Auth License Pledge in the fells-code governance document. In brief: there is no enterprise edition; single sign-on, SCIM, multi-factor authentication, organizations, and security features such as audit logging and admin controls are part of the open source product and stay in it; code released under Apache-2.0 will not be relicensed to a source-available or more restrictive license; and any managed offering charges for hosting and support, never for features missing from the open source release. The pledge is about where features live, not a list of what ships today: SAML and SCIM are not built yet.
Is passkey sign-in phishing-resistant?
Yes. A passkey is bound to your site’s domain, so a look-alike site cannot use it. With
phishing_resistant_only: true, passkeys are the only way to start a session.
Fells Code, which maintains Seamless Auth, offers hands-on migration help: fellscode.com.