Set up Okta SCIM 2.0 provisioning and map lifecycle states correctly
For developers wiring Okta to a SCIM 2.0 service and needing predictable downstream behavior for create, activate, suspend, and deprovision events. This walks through the exact Okta app settings, SCIM endpoints to implement, lifecycle mapping rules, and the curl checks that prove the integration works end to end.
TL;DR — You need two things for reliable Okta SCIM provisioning: a SCIM base URL that does not redirect and supports the standard user lifecycle operations, and explicit server-side mapping from Okta's user state changes to your downstream actions. The most common failure is pointing Okta at a URL that returns
301/302or implementing onlyPOST /Userswithout handlingPATCH activeupdates. Reading time: ~5 min
Goal
When you finish, Okta can provision users into your SCIM 2.0 endpoint, update profile attributes, activate or suspend accounts via active, and your application will translate those lifecycle changes into the exact downstream actions you want, with repeatable verification from both Okta and curl.
Prerequisites
- Okta admin access with permission to create or edit an app integration and enable provisioning
- A SCIM 2.0 service reachable from Okta over HTTPS on a public URL, for example
https://id.example.com/scim/v2 - A bearer token or equivalent auth secret that Okta will send to your SCIM service
- A test user in Okta that you can assign to the app
curl >= 8— check with:
curl --version
- Access to your application config or code where lifecycle states are mapped to downstream actions
- Server logs for the SCIM service
- If you run behind nginx or a load balancer, access to its config
Steps
Step 1: Confirm the SCIM base URL does not redirect
Run these checks against the exact URL you plan to enter in Okta.
SCIM_BASE="https://id.example.com/scim/v2"
curl -i "$SCIM_BASE/ServiceProviderConfig"
curl -I "$SCIM_BASE/Users"
Success looks like HTTP/1.1 200 for ServiceProviderConfig and not 301, 302, 307, or 308 for Users.
If misconfigured, the output usually looks like this:
HTTP/1.1 301 Moved Permanently
location: https://id.example.com/scim/v2/Users/
Okta integrations are much less painful when the exact configured URL returns the final response directly. If your framework auto-redirects missing trailing slashes, either disable that behavior for SCIM routes or enter the canonical path in Okta.
Step 2: Verify the minimum SCIM endpoints and auth behavior
Your service must answer these endpoints before you touch Okta:
TOKEN="replace-with-real-token"
SCIM_BASE="https://id.example.com/scim/v2"
curl -i -H "Authorization: Bearer $TOKEN" "$SCIM_BASE/ServiceProviderConfig"
curl -i -H "Authorization: Bearer $TOKEN" "$SCIM_BASE/ResourceTypes"
curl -i -H "Authorization: Bearer $TOKEN" "$SCIM_BASE/Schemas"
curl -i -H "Authorization: Bearer $TOKEN" "$SCIM_BASE/Users?startIndex=1&count=2"
Success looks like 200 OK and JSON responses with schemas fields. If auth is wrong, expect 401 Unauthorized or 403 Forbidden; fix that before proceeding.
A realistic ServiceProviderConfig shape:
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
"patch": {"supported": true},
"bulk": {"supported": false},
"filter": {"supported": true, "maxResults": 200},
"changePassword": {"supported": false},
"sort": {"supported": true},
"etag": {"supported": true},
"authenticationSchemes": [{"type": "oauthbearertoken", "name": "Bearer Token", "primary": true}]
}
Step 3: Implement lifecycle mapping in your SCIM service
Okta commonly expresses lifecycle changes as SCIM updates to active. Do not treat only DELETE as deprovisioning. Use this mapping in your app logic.
{
"okta_event_to_scim": {
"assign_user": "POST /Users",
"profile_change": "PATCH /Users/{id} or PUT /Users/{id}",
"unassign_user": "PATCH /Users/{id} active=false",
"suspend_user": "PATCH /Users/{id} active=false",
"unsuspend_or_reactivate_user": "PATCH /Users/{id} active=true"
},
"scim_to_downstream_action": {
"POST /Users": "create local account; set status=active if active=true else suspended",
"PATCH active=false": "disable login sessions; revoke API tokens; keep data intact",
"PATCH active=true": "re-enable login; do not recreate account if externalId matches existing record",
"DELETE /Users/{id}": "optional hard delete only if your policy explicitly allows it"
}
}
Success looks like your code has one explicit branch for active=false and one for active=true, and neither branch destroys data unless that is your deliberate policy.
Step 4: Return stable identifiers and support lookup by filter
Okta needs to correlate users. Your POST /Users response should include a stable id, and your service should support lookup by user name or email filter.
Example create response:
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"id": "9f3c2d5a-7d1e-4e6f-9c2b-1a2b3c4d5e6f",
"externalId": "00u123example",
"userName": "alice@example.com",
"active": true,
"meta": {
"resourceType": "User",
"location": "https://id.example.com/scim/v2/Users/9f3c2d5a-7d1e-4e6f-9c2b-1a2b3c4d5e6f"
}
}
Test filter support:
curl -i -H "Authorization: Bearer $TOKEN" "$SCIM_BASE/Users?filter=userName%20eq%20%22alice@example.com%22"
Success looks like 200 OK with a ListResponse containing either totalResults: 0 or the matching user.
Step 5: Enter the SCIM settings in Okta
In Okta Admin Console, open your app integration and go to:
Applications → Applications → <your app> → Provisioning → Integration
Enter these literal values:
SCIM connector base URL: https://id.example.com/scim/v2
Unique identifier field for users: userName
Supported provisioning actions: Import New Users and Profile Updates; Push New Users; Push Profile Updates; Push User Deactivation
Authentication Mode: HTTP Header
Authorization: Bearer replace-with-real-token
Then click the button that validates credentials in that same section.
Success looks like Okta accepts the credentials and lets you save the provisioning settings without an auth or connectivity error.
Step 6: Assign a test user and trigger lifecycle changes
In Okta Admin Console, go to:
Applications → Applications → <your app> → Assignments → Assign → Assign to People
Assign your test user, then change that same user's app assignment or account state to exercise lifecycle transitions.
Use these exact checks from your server side while you trigger each action:
tail -f /var/log/your-scim-service.log
Expected request shapes in logs:
{"method":"POST","path":"/scim/v2/Users","userName":"alice@example.com","active":true}
{"method":"PATCH","path":"/scim/v2/Users/9f3c2d5a-7d1e-4e6f-9c2b-1a2b3c4d5e6f","operations":[{"op":"Replace","path":"active","value":false}]}
{"method":"PATCH","path":"/scim/v2/Users/9f3c2d5a-7d1e-4e6f-9c2b-1a2b3c4d5e6f","operations":[{"op":"Replace","path":"active","value":true}]}
Success looks like assignment creates the user, unassignment or suspension sends active=false, and reactivation sends active=true against the same user record.
⚠️ If your downstream action for
active=falsedeletes data, stop and change it before testing with real users. Okta can send deactivation for routine offboarding or temporary suspension; destructive deletion here causes preventable data loss.
Step 7: If you are behind nginx, disable SCIM route redirects
A common production issue is nginx normalizing paths and returning redirects that Okta does not follow the way you expect.
location /scim/v2/ {
proxy_pass http://scim_backend;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
}
location = /scim/v2 {
return 301 /scim/v2/;
}
If Okta is configured with https://id.example.com/scim/v2, keep that exact canonical form stable. If you use the config above, enter https://id.example.com/scim/v2/ everywhere consistently instead.
Success looks like curl -I to the exact configured URL returns the final expected status without an extra hop.
Verify it works
Run direct API checks first:
curl -i -H "Authorization: Bearer $TOKEN" "$SCIM_BASE/ServiceProviderConfig"
curl -i -H "Authorization: Bearer $TOKEN" "$SCIM_BASE/Users?filter=userName%20eq%20%22alice@example.com%22"
Expected results:
HTTP/1.1 200 OK
Content-Type: application/scim+json
Then verify end to end in Okta:
1. Assign test user to the app.
2. Confirm your SCIM logs show POST /Users.
3. Suspend or unassign the same user.
4. Confirm your SCIM logs show PATCH active=false.
5. Reactivate or reassign the same user.
6. Confirm your SCIM logs show PATCH active=true for the same SCIM id.
Final proof in your app: the user can sign in after activation, cannot sign in after deactivation, and their account record was not duplicated across transitions.
Common pitfalls
Base URL redirects
Mistake: entering https://id.example.com/scim/v2 while the server redirects to /scim/v2/ or vice versa.
Symptom: Okta connection test fails or provisioning calls never hit your app; curl -I shows 301 or 308.
Fix: enter the canonical URL exactly as served, or remove the redirect for SCIM routes.
No support for PATCH active
Mistake: implementing create and profile update but ignoring lifecycle changes on active.
Symptom: users are created successfully, but suspend/unassign in Okta does nothing downstream.
Fix: implement PATCH /Users/{id} with Operations handling for path: active and map false to disable, true to re-enable.
Returning a new user ID on reactivation
Mistake: recreating the account on active=true instead of reusing the existing record.
Symptom: duplicate users, broken ownership links, or repeated welcome flows.
Fix: key reconciliation on your stable SCIM id and optionally externalId; reactivation must update the existing record.
Filter queries not implemented correctly
Mistake: not supporting filter=userName eq "..." or parsing it incorrectly.
Symptom: Okta cannot find existing users and attempts duplicate creates.
Fix: support at least equality filters on userName, return a valid SCIM ListResponse, and test with the exact encoded curl command in this article.
Wrong content type or malformed SCIM error body
Mistake: returning generic JSON or HTML errors from a reverse proxy.
Symptom: Okta shows opaque provisioning errors; your logs show 415, 500, or HTML responses.
Fix: return Content-Type: application/scim+json and SCIM-shaped error bodies from the app, not an HTML error page from nginx or the load balancer.
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