Skip to main content

Migrating from AWS Cognito

The migration is manual: Cognito's ListUsers API exports the data you need, and the steps below reshape it and create each user through the LumoAuth Admin API.

1. Export Cognito users​

aws cognito-idp list-users \
--user-pool-id us-east-1_AbC123 \
--limit 60 \
> cognito-users.json

ListUsers paginates 60 at a time; loop with --pagination-token to get the rest:

TOKEN=""
while :; do
if [ -n "$TOKEN" ]; then
aws cognito-idp list-users --user-pool-id us-east-1_AbC123 --limit 60 \
--pagination-token "$TOKEN" > "page-$(uuidgen).json"
else
aws cognito-idp list-users --user-pool-id us-east-1_AbC123 --limit 60 \
> "page-$(uuidgen).json"
fi
TOKEN=$(jq -r '.PaginationToken // empty' "page-*.json" | tail -1)
[ -z "$TOKEN" ] && break
done
jq -s 'map(.Users) | flatten' page-*.json > cognito-users.json

2. Map Cognito fields to LumoAuth​

The LumoAuth Admin user-create endpoint reads these top-level fields: email, username, name, givenName, familyName, phoneNumber, password, isActive, emailVerified, roles and groups. Cognito attributes are key/value tuples in Attributes[] rather than nested objects:

Cognito attribute (Attributes[*].Name)LumoAuth field
emailemail
email_verified (== "true")emailVerified
namename
given_name, family_namegivenName, familyName
phone_numberphoneNumber
UserStatus == "CONFIRMED" (top-level)isActive
Username, sub (top-level and in Attributes)Your own mapping table (see step 3)
Custom attributes (custom:foo)Your own database, keyed by the LumoAuth user id
UserCreateDateNot accepted; LumoAuth sets createdAt at import time

Phone verification state and custom attributes are not stored by the create endpoint; keep them on your side.

3. Create the users​

The Admin API creates one user per request at POST /orgs/{orgId}/api/v1/admin/users (there is no bulk endpoint). Reshape the export into one JSON object per line, POST each one, and record the Cognito sub next to the LumoAuth id from the response:

# Reshape Cognito users into newline-delimited LumoAuth user objects,
# carrying the Cognito sub along for the mapping table
jq -c '.[] | {
cognito_sub: (.Attributes[] | select(.Name == "sub") | .Value),
user: {
email: (.Attributes[] | select(.Name == "email") | .Value),
emailVerified: ((.Attributes[] | select(.Name == "email_verified") | .Value) == "true"),
name: ([.Attributes[] | select(.Name == "name") | .Value][0]),
givenName: ([.Attributes[] | select(.Name == "given_name") | .Value][0]),
familyName: ([.Attributes[] | select(.Name == "family_name") | .Value][0]),
phoneNumber: ([.Attributes[] | select(.Name == "phone_number") | .Value][0]),
isActive: (.UserStatus == "CONFIRMED")
}
}' cognito-users.json > lumo-users.ndjson

# POST each user; an existing-email 409 is expected on re-runs.
# Writes "cognito_sub,lumo_user_id" lines to mapping.csv.
while read -r row; do
sub=$(echo "$row" | jq -r '.cognito_sub')
user=$(echo "$row" | jq -c '.user')
id=$(curl -sS -X POST "$LUMO_BASE_URL/orgs/$LUMO_ORG/api/v1/admin/users" \
-H "Authorization: Bearer $LUMO_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "$user" | jq -r '.data.id // empty')
[ -n "$id" ] && echo "$sub,$id" >> mapping.csv
done < lumo-users.ndjson

The loop is idempotent: a 409 Conflict (email already exists) means the user was already created and can be safely skipped on re-runs. For users created on an earlier run, GET /admin/users/{email} (or lumo users get <email> -o json) returns the id to backfill the mapping.

$LUMO_ADMIN_TOKEN is an access token with the admin scope, or use an organization API key instead (-H "Authorization: ApiKey lmk_…", with the admin:users:write scope). The same request works as lumo api POST /users --data "$user".

4. Migrate Cognito Groups → LumoAuth Groups​

aws cognito-idp list-groups --user-pool-id us-east-1_AbC123 \
| jq -r '.Groups[] | "lumo groups create --name \"\(.GroupName)\" --description \"\(.Description // "")\""' \
| bash

For each group, list its members (aws cognito-idp list-users-in-group) and add them in LumoAuth with lumo groups members add <group-id> --users <email,…>.

5. What's intentionally dropped​

  • Password hashes. Cognito holds them server-side; they're not exportable. Users password-reset on first sign-in.
  • MFA enrolments. Cognito MFA factors are pool-bound. Re-enrol.
  • Cognito identities (federated). If your users signed in only via Cognito's Hosted UI federated providers (Google, Facebook, Apple), they migrate as social-only users — they re-link the social provider in LumoAuth. Signing in with the provider links to the imported account only when it was imported with emailVerified: true and without a password, and the provider asserts a verified email; otherwise the sign-in is refused as "An account with this email already exists".
  • Cognito access / refresh tokens. Not honored by LumoAuth.

6. Cutover with a Pre Token Generation Lambda​

A common Cognito migration pattern is to keep Cognito as the auth front for a window, with a Lambda trigger that also writes the user to LumoAuth on each successful sign-in. After 90 days you've migrated the active user base; flip the front door to LumoAuth and decommission Cognito.

If you're using Cognito's User Migration Lambda feature for importing users (the inverse of what we want), the same Lambda hook can also write to LumoAuth — making the migration symmetric and reversible.

What you gain over Cognito​

  • Real fine-grained authz. Cognito groups are RBAC-only (access decided by roles); LumoAuth ships RBAC + ABAC (rules on user and resource attributes) + Zanzibar-style relationships.
  • Hosted UI customization. Cognito's Hosted UI is famously rigid (CSS-only customization, no per-app branding). LumoAuth allows per-OAuth-client logo + colors + layout.
  • MCP / agent identity. Cognito has no concept of agent principals.
  • Predictable pricing. Cognito's pricing surprises at scale; LumoAuth Business ($449/mo) is flat to 100K MAU.

Full migrations index →