Migrating from Okta
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.
What moves, and what does not
| In Okta | In auth-service | Notes |
|---|---|---|
| OIDC web / SPA / native app | Application (confidential or isPublic) | Created again; client ids and secrets are not portable |
| Service app (client credentials) | Confidential application | Token scope is m2m |
Authorization server and its audience (api://default) | The service itself; audience = the application url | One issuer for every application |
| Custom scopes | Roles and permissions | The scope list is fixed |
Groups, groups claim | Roles, in roles and, with the profile, in groups | One role per user per application (when migrated with the command) |
| Custom claims (expressions) | permissions, features, plan, org_id, claim mappings | No expression language |
| Users | Users, with their Okta id as id | Imported by the command |
| Passwords | Not exportable | Users set a new one |
| MFA (Okta Verify, SMS, WebAuthn) | TOTP with backup codes, plus passkeys (WebAuthn) as a separate mechanism | No 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 routing | Google and GitHub | Other 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
resourcestill yields a JWT, whoseaudis the applicationurl(Okta's authorization server audience); - tokens carry
groups(the role names, when thegroupsscope is requested),scp(the scopes, as an array),preferred_username(the e-mail, withprofile) andcid(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
| Okta | auth-service | |
|---|---|---|
| Issuer | https://<org>.okta.com/oauth2/default | https://auth.example.com (the root, no path) |
client_id | Generated | The application slug |
| Client secret | Basic or POST | HTTP Basic, unless TOKEN_ENDPOINT_AUTH_METHOD=post |
| Scopes | groups is a scope | Allow groups in the application's allowedScopes. The admin form does not list it, so set it through the API (allowedScopes) |
| Logout | /v1/logout | end-session, off by default per application (enableEndSession) and global |
sub | 00u… | 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.
| Option | Effect |
|---|---|
--apply | Perform 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,b | A 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 true | Mark 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-verification | E-mail a verification link to users that are not verified (off by default, to avoid a mass mailing) |
--no-keep-ids | Do 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.
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
- Tell users what changes: new sign-in page, set a new password, enroll the authenticator app again.
- Switch one application (or one group of users) at a time; the dual-issuer verifier keeps old sessions valid.
- Follow the login history to see who has moved. Spread the password reset e-mails over time: they are rate limited.
- When no traffic goes through Okta any more, remove its issuer from the verifier and delete the Okta applications.
LiteLLM app and wallet
Let your application call LLMs and MCP tools through the LiteLLM gateway on behalf of its users, and understand how the shared credit wallet is consumed and refilled.
Migrating from Auth0
Move an application and its users from Auth0 to auth-service with the Auth0 compatibility profile and the migrate:auth0 command, keeping ids and passwords.