Skip to content

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-cli and seamless-auth-api source, and the export loop and jq conversion were run against sample list-users output.
  • 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.


Seamless Auth has no password sign-in, so there are no passwords to migrate. A migration off Cognito is two things:

  1. 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.
  2. 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.

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.


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. ListUsers returns at most 60 users per call. The AWS CLI pages automatically, so --page-size 60 makes each underlying call as large as it can be.
  • Rate limit. ListUsers and ListUsersInGroup share the UserList request 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 of Name/Value pairs, including sub, email, email_verified, phone_number, and custom: attributes), Enabled, UserStatus, and timestamps. ListUsers is 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:

Terminal window
POOL_ID=us-east-1_EXAMPLE
aws cognito-idp list-users --user-pool-id "$POOL_ID" --page-size 60 \
| jq '.Users' > cognito-users.json

For 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:

Terminal window
POOL_ID=us-east-1_EXAMPLE
mkdir -p cognito-export
token=""
page=0
while :; 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" ] && break
done
jq -s '[.[].Users[]]' cognito-export/users-*.json > cognito-users.json

The 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:

Terminal window
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.json

The 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,organizations
  • email is required. externalId is the Cognito sub.
  • roles and organizations take several values separated by ;. Organizations are referenced by slug or id and must already exist, and roles must be in the instance’s available_roles. The import never grants admin or an admin:* 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 in cognito-groups.json first.
  • 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:

Terminal window
# Users with no email
jq -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.json

For 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:

Terminal window
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.csv

Change 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.


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:

Terminal window
seamless profile login production
seamless org create "Platform" --slug platform
seamless migrate csv users.csv --source cognito

The 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:

Terminal window
seamless migrate csv users.csv --source cognito --apply

Keep 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.


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.

  1. 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.com or a custom domain (user pool endpoints).

  2. 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 the openid and email scopes, 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 to email and email_verified for this client; Cognito rejects the token request when a client asks for the email scope but cannot read email_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.

  3. 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.

  4. Add Cognito as a provider. Copy issuer and jwks_uri exactly from the pool’s discovery document, https://cognito-idp.<region>.amazonaws.com/<user-pool-id>/.well-known/openid-configuration. The issuer is usually https://cognito-idp.<region>.amazonaws.com/<user-pool-id>, with no trailing slash, but a pool on Cognito’s updated issuer uses https://issuer-cognito-idp.<region>.amazonaws.com/<user-pool-id> (Cognito as an OIDC issuer), and the value must match the token’s iss claim exactly. ID tokens are signed with RS256, which Seamless Auth accepts; it rejects symmetrically signed tokens. Save as cognito-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.json
    • externalIdSource must equal the --source you imported with, and externalIdJsonPath: "sub" reads the Cognito sub from the verified ID token.
    • allowSignup: false and accountLinking: "disabled" mean Cognito can only sign in accounts you imported. It cannot create new accounts or attach itself to other accounts by email.
    • promptPasskeyEnrollment: true makes the callback answer nextStep: 'enroll_passkey' for a user without a passkey. AuthRoutes in @seamless-auth/react 0.14.0 and later sends them straight to enrollment.

    oauth must 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.


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:

Terminal window
seamless config set prompt_passkey_enrollment true

Then track and chase coverage:

  • GET /admin/enrollment lists users with their passkey status (none, one, two_or_more), filterable by organization and by imported=true. The admin dashboard shows it on the Passkey enrollment screen.
  • POST /admin/enrollment/invites emails 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-coverage reports passkey coverage overall, per organization, and over time, with format=csv for 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.


Retire Cognito sign-in per organization once its members are enrolled, or all at once.

  • Per organization: PUT /admin/organizations/:organizationId/oauth-providers/cognito/retirement refuses 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. DELETE on 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 with seamless 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.


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.