Migration

Migrating from Okta

Move an application and its users from Okta to auth-service with the Okta compatibility profile and the migrate:okta command, then cut over with both issuers accepted.

Okta and auth-service both speak OpenID Connect. The Okta compatibility profile removes most of the differences that applications notice, and pnpm migrate:okta moves the users, their groups and their ids. What cannot move is the passwords and the second factors: Okta does not export them.

auth-service has no SAML, no LDAP / Active Directory federation, no SCIM provisioning and no Okta Workflows equivalent. An application that depends on one of them cannot move yet.

What moves, and what does not

In OktaIn auth-serviceNotes
OIDC web / SPA / native appApplication (confidential or isPublic)Created again; client ids and secrets are not portable
Service app (client credentials)Confidential applicationToken scope is m2m
Authorization server and its audience (api://default)The service itself; audience = the application urlOne issuer for every application
Custom scopesRoles and permissionsThe scope list is fixed
Groups, groups claimRoles, in roles and, with the profile, in groupsOne role per user per application (when migrated with the command)
Custom claims (expressions)permissions, features, plan, org_id, claim mappingsNo expression language
UsersUsers, with their Okta id as idImported by the command
PasswordsNot exportableUsers set a new one
MFA (Okta Verify, SMS, WebAuthn)TOTP with backup codes, plus passkeys (WebAuthn) as a separate mechanismNo SMS second factor; e-mail one-time codes exist only as an optional sign-in method (EMAIL_OTP_ENABLED), not as a second factor. Users enroll again
Social / IdP routingGoogle and GitHubOther providers are not wired
Management API (SSWS token)Admin REST API (session cookie)No API tokens for the admin API

1. Configure the instance

COMPAT_PROFILE=okta

With this profile, for all applications of the instance:

  • access and ID tokens are signed with RS256, as Okta's are;
  • a request without resource still yields a JWT, whose aud is the application url (Okta's authorization server audience);
  • tokens carry groups (the role names, when the groups scope is requested), scp (the scopes, as an array), preferred_username (the e-mail, with profile) and cid (the client id);
  • /oauth2/default/v1/{authorize,token,keys,userinfo,introspect,revoke,logout} and the same paths under /oauth2/v1/… answer like the canonical /api/auth/oauth2/… endpoints — Okta's default and org authorization servers.

Details and overrides: Compatibility profiles. Set it before the first start: the signature algorithm is chosen when the first key is created.

Also configure SMTP (users reset their password by e-mail) and your branding (custom templates).

2. What still changes in an application

Oktaauth-service
Issuerhttps://<org>.okta.com/oauth2/defaulthttps://auth.example.com (the root, no path)
client_idGeneratedThe application slug
Client secretBasic or POSTHTTP Basic, unless TOKEN_ENDPOINT_AUTH_METHOD=post
Scopesgroups is a scopeAllow groups in the application's allowedScopes. The admin form does not list it, so set it through the API (allowedScopes)
Logout/v1/logoutend-session, off by default per application (enableEndSession) and global
sub00u…Unchanged: the Okta id is kept

An application that reads the issuer from its configuration needs the new URL and client id, and nothing else.

Create each application first (Managing applications): slug, url (the audience), the same redirect URIs, isPublic for SPAs and native apps, and groups among the allowed scopes (through the API, see above).

3. Move the users

# Dry run: reads Okta, writes nothing
OKTA_ORG_URL=https://acme.okta.com OKTA_API_TOKEN=<token> \
  pnpm migrate:okta -- --app acme-app --role-priority Admins,Editors

# For real
AUTH_SERVICE_URL=https://auth.example.com ADMIN_EMAIL=… ADMIN_PASSWORD=… \
OKTA_ORG_URL=… OKTA_API_TOKEN=… \
  pnpm migrate:okta -- --app acme-app --role-priority Admins,Editors --trust-email true --apply

The command reads the active users and the groups of type OKTA_GROUP (Okta's built-in Everyone group and application groups are ignored), then (with --apply) imports them with their Okta id, creates a role per group in the application and grants access in bulk. Users in no group still get access, with the application's default role if it has one. The e-mail address is the user's profile.email, or profile.login when there is none.

--apply needs BETTER_AUTH_URL (read from .env when not exported), ADMIN_EMAIL and ADMIN_PASSWORD (at least 12 characters). AUTH_SERVICE_URL, when set, is the address the API calls are sent to instead of BETTER_AUTH_URL. The administrator must be a superadmin (the import sets ids, verified flags and password hashes, which only superadmins may do) and must not have a second factor enrolled: the command signs in through the e-mail/password endpoint.

OptionEffect
--applyPerform the migration. Without it nothing is written
--app <slug>Grant access to this application; Okta groups become its roles. Cannot be combined with --no-keep-ids
--role-priority a,bA user has one role per application: the first listed group wins, else the first alphabetically. Users in several groups are listed in the report
--trust-email trueMark addresses verified. Okta does not say whether an address is verified; active users completed an activation by e-mail. Defaults to false for Okta (the value is true or false)
--send-verificationE-mail a verification link to users that are not verified (off by default, to avoid a mass mailing)
--no-keep-idsDo not keep the Okta id as the user id
--report <file>Where to write the JSON report (users, roles, issues, errors: no secrets). With --apply and no --report, it is written to migration-report-okta-<timestamp>.json

The command exits with code 1 when some users failed to import. A second run is harmless: users already there are reported and still receive their access. Invalid or duplicate addresses are skipped and listed.

Group names become role names: lower-cased, characters outside a-z0-9_.- replaced by -, 64 characters at most. --role-priority values are compared after the same normalisation, so --role-priority Admins,Editors matches the groups Admins and Editors.

Users have no password yet: they use Forgot your password? on the sign-in page. The temporary passwords that the import generates are never written to the report file.

Prefer a CSV for a handful of users? Users → Import users in the admin interface takes name, email, password (optional) and role. The command goes through the same import endpoint, which accepts id, emailVerified and passwordHash for superadmins (API reference).

4. Accept both issuers during the transition

Keep Okta working while users move. Choose the verification setup from the token's iss, then verify fully against that issuer: reading iss first only selects the configuration, nothing is trusted until jwtVerify succeeds.

import { createRemoteJWKSet, decodeJwt, jwtVerify } from "jose";

const issuers = {
  "https://auth.example.com": {
    jwks: createRemoteJWKSet(new URL("https://auth.example.com/api/auth/jwks")),
    audience: "https://api.acme.com", // the application url
  },
  "https://acme.okta.com/oauth2/default": {
    jwks: createRemoteJWKSet(new URL("https://acme.okta.com/oauth2/default/v1/keys")),
    audience: "api://default",
  },
} as const;

export async function verify(token: string) {
  const iss = decodeJwt(token).iss;
  const cfg = iss ? issuers[iss as keyof typeof issuers] : undefined;
  if (!cfg) throw new Error("Unknown issuer");
  // RS256 on both sides with the Okta profile.
  const { payload } = await jwtVerify(token, cfg.jwks, {
    issuer: iss,
    audience: cfg.audience,
    algorithms: ["RS256"],
  });
  return payload; // sub is the same Okta id on both issuers; groups is present on both
}

For the sign-in side, replace the Okta SDK by a generic OIDC client (Integrating a third-party application): new issuer and client id, PKCE as before. With the Okta profile you do not need to add resource.

5. Move users in waves, then retire Okta

  1. Tell users what changes: new sign-in page, set a new password, enroll the authenticator app again.
  2. Switch one application (or one group of users) at a time; the dual-issuer verifier keeps old sessions valid.
  3. Follow the login history to see who has moved. Spread the password reset e-mails over time: they are rate limited.
  4. When no traffic goes through Okta any more, remove its issuer from the verifier and delete the Okta applications.
Copyright © 2026