Okta org-to-org migration: what moves, what breaks, and coexistence
For developers and platform engineers planning an Okta tenant migration without breaking sign-in. This guide explains what typically transfers, what must be rebuilt, and how to run old and new orgs in parallel long enough to cut over safely.
TL;DR — An Okta org-to-org migration is not a database restore into a new tenant; treat it as a staged rebuild plus selective data migration. The safest default is: recreate configuration as code where possible, bulk-migrate identities with a password strategy, federate or route traffic so both orgs can authenticate during transition, and cut over app-by-app behind a tested custom domain. Reading time: ~7 min
What it is and where it sits
An Okta org-to-org migration is moving identity workloads from one Okta tenant to another: users, groups, app integrations, sign-on policies, branding, custom domains, inbound/outbound federation, lifecycle hooks, and API clients. In practice, only some of that is exportable or reproducible directly. The rest is re-created manually or through APIs/Terraform, then validated app by app.
Architecture-wise, the org sits in the middle of your auth path. Apps redirect browsers to it for OIDC/SAML, APIs trust tokens minted by it, and provisioning systems push users/groups into or out of it. During migration, you are changing the issuer, metadata endpoints, certificates, client IDs, and sometimes usernames or login routing.
What it replaces: the old org as the identity control plane.
What talks to it:
- Browser-based apps using OIDC or SAML
- Backend APIs validating JWTs against the org’s issuer/JWKS
- SCIM/provisioning connectors
- HRIS/LDAP/AD agents or import jobs
- Admin automation using the Okta API or Terraform provider
Typical request flow during coexistence:
[User Browser]
|
| 1. GET /app
v
[Your App] ---- 2. redirect to authorize ----> [New Okta Org]
^ |
| | 3a. local auth succeeds
| |
|<----- 5. code/id_token/access_token ---------|
|
| | 3b. OR route/federate to old org
| v
|----------------------------------------> [Old Okta Org]
|
| 4. authenticate user
v
[User returns]
The important architectural point: apps and APIs usually care about issuer URLs, metadata, signing keys, and group/claim shape. Users care about login continuity, MFA prompts, and password reset friction. Migration planning fails when teams focus only on “copying users” and ignore token issuers, certificates, and policy behavior.
How it actually works
Use one realistic example: you have login.example.com on the old org, 40 OIDC apps, 6 SAML apps, users sourced from HR + some local users, and several APIs that validate access tokens using the old issuer. Goal: move to a new org with minimal downtime and a two-week coexistence period.
Step 1: Inventory what depends on the old org
Pull a dependency list before touching config:
- Every app and whether it uses OIDC or SAML
- Redirect URIs / ACS URLs
- Issuer URL in each app and API
- Group claims and app assignments
- Custom domain and certificate ownership
- Inbound federation and external IdPs
- MFA/policy rules that affect sign-in behavior
- Service accounts, API tokens, Terraform state, hooks, SCIM connectors
If your apps validate JWTs directly, search code and config for the old issuer.
grep -R "https://old-org.example.okta.com\|https://login.example.com" ./services ./infra
Typical hits that matter:
services/payments/.env:OKTA_ISSUER=https://login.example.com/oauth2/default
services/gateway/values.yaml:issuer: https://old-org.example.okta.com/oauth2/default
infra/terraform/oidc.tf:issuer = "https://login.example.com"
Step 2: Build the new org as a target, not as a clone
Do not assume “export/import org” exists in a complete, lossless sense. In most real migrations:
- Users and groups can often be bulk-created or synchronized.
- Passwords may or may not be transferable depending on source, hashing compatibility, and migration method.
- App integrations are usually re-created because client IDs, secrets, signing certs, and metadata are org-specific.
- Policies, branding, custom domains, email templates, hooks, and admin roles are often partially manual or API-driven rebuilds.
Use infrastructure-as-code where you can so the new org is reproducible. Even if you still click through some provider dashboard pages, keep the source of truth in code for apps, groups, and policies.
Step 3: Decide your password and coexistence strategy
This is the hard part. There are three common patterns:
- Authoritative upstream source exists: users come from AD/LDAP/HR/another IdP. Reconnect the new org to that source and let users authenticate there. Least painful.
- Password import supported: migrate users with password hashes or provider-supported import flow. Good if available and tested.
- Just-in-time fallback: users start at the new org; if not yet migrated or password unavailable, authenticate against old org via federation/routing, then progressively move them. Good for coexistence, more moving parts.
For a two-week overlap, pattern 3 is often safest because it avoids a mass password reset on day one.
Step 4: Recreate one app end to end and prove the path
Take one OIDC app first.
- Create the app integration in the new org.
- Add the same redirect URIs.
- Recreate claim mappings and group assignments.
- Update a non-production copy of the app with the new issuer/client ID/secret.
- Validate token issuance and API JWT verification.
A quick metadata/JWKS sanity check:
curl -s https://login-new.example.com/oauth2/default/.well-known/openid-configuration | jq '.issuer,.authorization_endpoint,.jwks_uri'
Expected shape:
"https://login-new.example.com/oauth2/default"
"https://login-new.example.com/oauth2/default/v1/authorize"
"https://login-new.example.com/oauth2/default/v1/keys"
Then check the custom domain before cutover. A broken redirect or TLS chain here will look like an app bug but is usually DNS/cert setup.
curl -I https://login-new.example.com/
Healthy output shape:
HTTP/2 200
content-type: text/html; charset=utf-8
strict-transport-security: max-age=31536000; includeSubDomains
Misconfigured custom domain often looks like:
curl: (60) SSL certificate problem: no alternative certificate subject name matches target host name 'login-new.example.com'
or:
HTTP/2 404
server: cloudfront
x-cache: Error from cloudfront
That means fix DNS/TLS ownership in your provider dashboard before testing app auth.
Step 5: Run both orgs at once
The cleanest coexistence model is usually one of these:
- Per-app cutover: some apps trust old org, others trust new org. Lowest auth-path complexity, but users may see different login experiences.
- Front-door on new org with fallback federation to old org: users start at new org; selected users/apps route to old org during transition. Better user consistency, more identity plumbing.
For OIDC apps, remember issuer changes are breaking changes for token validation. Your APIs must trust the new issuer before the app switches. During overlap, some APIs may need to trust both issuers.
Step 6: Cut over the custom domain last
Move login.example.com only after:
- new org app config is validated
- APIs trust new issuer/JWKS
- federation/routing fallback works
- rollback plan is written and tested
⚠️ Changing the custom login domain can break every browser-based sign-in at once. Lower DNS TTL well before the change window, and keep the old org configuration intact until you have verified real user logins and token validation in production.
After cutover, test from a clean browser profile and from a backend service that validates JWTs.
When to use it (and when not to)
| Scenario | Recommendation |
|---|---|
| You need a new org because of environment separation, acquisition, region/legal boundary, or a bad original tenant design | Do it as a staged rebuild plus migration. Treat config as code and plan coexistence. |
| Most users authenticate via upstream IdP/AD and apps are modern OIDC | Good candidate. Password pain is lower; app-by-app cutover is manageable. |
| You have many SAML apps with brittle claim mappings and manual cert distribution | Still possible, but budget more time for metadata/cert coordination and partner testing. |
| You expect a one-click full clone including policies, app secrets, passwords, domains, and certs | You probably don’t need “migration planning”; you need a reality check. Assume manual rebuild for significant parts. |
| You only need branding cleanup or policy changes | You probably don’t need a new org. Fix the existing one if governance allows it. |
| You have hard-coded issuer URLs in many services and no inventory | Don’t start migration yet. First build dependency visibility and dual-issuer support where needed. |
Trade-offs
- Benefit: cleaner tenant design → Cost: rebuild effort. You get sane groups, policies, naming, and app hygiene, but you will spend time recreating integrations and validating edge cases.
- Benefit: safer cutover with coexistence → Cost: temporary complexity. Running both orgs reduces blast radius, but now you have dual issuers, duplicate assignments, and more support burden.
- Benefit: app-by-app migration → Cost: inconsistent user experience. Some apps hit old login, others new. Support needs a matrix of expected behavior.
- Benefit: front-door on new org → Cost: federation/routing complexity. Cleaner UX, but more places for loops, claim mismatches, and MFA policy surprises.
- Benefit: preserve passwords where possible → Cost: implementation constraints. Hash portability and migration method may limit what you can do. If unsupported, you fall back to resets or JIT migration.
- Benefit: custom domain continuity → Cost: DNS/TLS coordination risk. One hostname preserves bookmarks and app config, but the cutover is operationally sensitive.
- Benefit: reproducible config via Terraform/API → Cost: upfront IaC work. Worth it if the org matters long term; overkill for a tiny temporary tenant.
In practice
Example 1: dual-issuer JWT validation during overlap
import jwksClient from 'jwks-rsa';
import jwt from 'jsonwebtoken';
const issuers = {
'https://login.example.com/oauth2/default': jwksClient({ jwksUri: 'https://login.example.com/oauth2/default/v1/keys' }),
'https://login-new.example.com/oauth2/default': jwksClient({ jwksUri: 'https://login-new.example.com/oauth2/default/v1/keys' })
};
function getKey(header, callback, issuer) {
const client = issuers[issuer];
if (!client) return callback(new Error(`untrusted issuer: ${issuer}`));
client.getSigningKey(header.kid, (err, key) => {
if (err) return callback(err);
callback(null, key.getPublicKey());
});
}
export function verifyAccessToken(token, expectedAudience) {
const decoded = jwt.decode(token, { complete: true });
if (!decoded?.payload?.iss) throw new Error('missing iss');
const issuer = decoded.payload.iss;
return new Promise((resolve, reject) => {
jwt.verify(token, (header, cb) => getKey(header, cb, issuer), {
algorithms: ['RS256'],
issuer: Object.keys(issuers),
audience: expectedAudience
}, (err, payload) => err ? reject(err) : resolve(payload));
});
}
This lets one API accept tokens from both old and new orgs during migration. Gotcha: keep the allowed issuer list explicit; do not trust arbitrary iss values just because they publish JWKS.
Typical failure when an app has switched but the API has not:
JsonWebTokenError: jwt issuer invalid. expected: https://login.example.com/oauth2/default
Example 2: pre-cutover checks for custom domain and OIDC metadata
set -euo pipefail
DOMAIN="login-new.example.com"
ISSUER="https://${DOMAIN}/oauth2/default"
echo "== DNS =="
dig +short ${DOMAIN}
echo "== TLS/HTTP =="
curl -svI https://${DOMAIN}/ 2>&1 | sed -n '1,20p'
echo "== OIDC metadata =="
curl -fsS ${ISSUER}/.well-known/openid-configuration | jq '{issuer,authorization_endpoint,jwks_uri}'
echo "== JWKS reachable =="
JWKS=$(curl -fsS ${ISSUER}/.well-known/openid-configuration | jq -r '.jwks_uri')
curl -fsS "$JWKS" | jq '.keys | length'
This is a cheap smoke test before changing app config or DNS. Gotcha: curl -f exits non-zero on HTTP 4xx/5xx, which is what you want in CI; without -f, a 404 page can look like success to a shell script.
Example failure shape:
curl: (22) The requested URL returned error: 404
Example 3: lower DNS TTL before custom-domain cutover
dig login.example.com
If your DNS provider supports API/CLI, lower the TTL for the custom domain record at least one propagation window before cutover. The exact command depends on provider, but the action is always the same in the provider dashboard: open the DNS record for login.example.com, set TTL to something short like 60 or 300 seconds, save, and verify with dig from multiple resolvers. Gotcha: some providers flatten CNAMEs or proxy traffic; if HTTPS starts terminating at the wrong edge, your certificate/host mismatch will surface during auth.
Further reading
- Okta Developer Docs: OIDC and OAuth 2.0 API
- Okta Help Center: Custom URL Domain
- Okta API Reference: Users, Groups, Apps, Policies
- OpenID Connect Core 1.0
- RFC 8414: OAuth 2.0 Authorization Server Metadata
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI