Deactivated Okta user still has app access: trace deprovisioning end to end
For developers and support engineers debugging why a user disabled in Okta can still reach downstream apps or APIs. This runbook walks the deprovisioning path from Okta status to SCIM, token/session lifetime, group assignment, and app-side account state, with concrete checks and fixes.
TL;DR — If a deactivated Okta user still has access, the usual culprit is not "Okta ignored the disable" but a break somewhere downstream: SCIM deprovisioning never fired, the app trusts long-lived sessions/tokens, or the app account was never linked to lifecycle events. Start by checking whether the user is actually deactivated in Okta, then inspect the app assignment and provisioning logs, and finally verify whether the app is honoring session/token revocation. Reading time: ~6 min
The scenario
It is Tuesday at 3:40 PM. HR confirms an employee was deactivated in Okta an hour ago, but your customer success lead just watched that same user open the SaaS app and pull live customer data. Okta shows the person as deactivated, the SSO tile is gone for admins viewing the account, and everyone is now asking whether offboarding is broken across the stack. You need to prove where the chain failed: Okta lifecycle, app assignment, SCIM push, token/session revocation, or the app's own local auth path.
Symptoms
- In Okta, the user shows as deactivated/disabled, but in the downstream app the user can still:
- load an existing browser session
- call APIs with an old bearer token
- appear as "active" in the app's admin UI
- Typical app-side logs still show successful auth for that identity:
INFO auth: session accepted user_id=8f2c1d2a email=alex@example.com method=saml
INFO api: 200 GET /v1/customers actor=alex@example.com
- SCIM or provisioning logs show no disable event, or a failed one:
POST /scim/v2/Users/2819 404 Not Found
PATCH /scim/v2/Users/2819 400 invalidPath
- The identity provider sign-in may fail for new logins, while old sessions continue working:
401 Unauthorized: user is not assigned to the client application
- JWT validation in the app still succeeds because the token is unexpired:
{"sub":"00u123...","exp":1785938400,"active":true}
- Audit trail mismatch:
- Okta audit: user deactivated
- App audit: no user suspension/deletion event after that timestamp
Likely causes
| Cause | How common | Quick check |
|---|---|---|
| Existing app session or JWT remains valid after Okta deactivation | Very common | `jwt decode "$TOKEN" |
| SCIM provisioning/deprovisioning is not enabled or failed | Very common | In Okta admin for the app: Provisioning or Push logs for the user |
| User was deactivated in Okta but app assignment/group removal did not propagate | Common | In Okta admin: open user → Applications / Groups and inspect current assignment source |
| App has a local account/password path independent of Okta | Common | Try app login with local password reset flow or inspect app auth config/env |
| App maps Okta deactivation incorrectly and only disables new SSO, not existing app account | Less common | `curl -s https://app.example.com/scim/v2/Users/<id> -H "Authorization: Bearer $SCIM_TOKEN" |
| Multiple identity sources or duplicate user records exist downstream | Less common | Query app DB/admin API by email and external ID |
Step-by-step diagnosis
-
Check the user's actual lifecycle state in Okta.
- In your Okta admin UI, open the user and confirm the status is deactivated/disabled, not merely suspended or unassigned from one app.
- What means "this is your problem": the user is still active, or only removed from the app assignment while global lifecycle is unchanged.
- Jump to: Fixes → User was deactivated in Okta but app assignment/group removal did not propagate if assignment is the issue.
-
Test whether the user can start a brand-new SSO session versus only continue an old one.
- Use a fresh browser profile/incognito and attempt login to the app through Okta.
- What means "this is your problem": new login fails, but an already logged-in browser still works. That points to stale app sessions or long-lived tokens.
- Jump to: Fixes → Existing app session or JWT remains valid after Okta deactivation.
-
Inspect the token/session lifetime the app is honoring.
- If you have a captured access token:
python - <<'PY'
import os, json, base64, time
jwt=os.environ['TOKEN'].split('.')
payload=jwt[1] + '=' * (-len(jwt[1]) % 4)
print(json.dumps(json.loads(base64.urlsafe_b64decode(payload)), indent=2))
print('now=', int(time.time()))
PY
- What means "this is your problem":
expis still in the future and the app accepts the token without introspection/revocation checks. - Jump to: Fixes → Existing app session or JWT remains valid after Okta deactivation.
-
Check whether Okta actually attempted downstream deprovisioning.
- In the app integration's provisioning logs or system logs, filter by the user and look for SCIM
PATCH,PUT, orDELETEevents around the deactivation timestamp. - What means "this is your problem": no outbound deprovision event exists, or it exists but failed with 4xx/5xx.
- Jump to: Fixes → SCIM provisioning/deprovisioning is not enabled or failed.
- In the app integration's provisioning logs or system logs, filter by the user and look for SCIM
-
Validate the downstream user state directly through SCIM or the app admin API.
curl -sS https://app.example.com/scim/v2/Users/2819 \
-H "Authorization: Bearer $SCIM_TOKEN" \
-H "Accept: application/scim+json" | jq '{id,userName,active,externalId}'
- Example bad result:
{
"id": "2819",
"userName": "alex@example.com",
"active": true,
"externalId": "00uabc123xyz"
}
- What means "this is your problem":
active: trueafter Okta deactivation, orexternalIdmissing/mismatched. - Jump to: Fixes → App maps Okta deactivation incorrectly and only disables new SSO, not existing app account or Fixes → Multiple identity sources or duplicate user records exist downstream.
-
Verify assignment and group-driven provisioning.
- In Okta admin, inspect the user's app assignment source: direct assignment, group assignment, or both. Then inspect whether the user is still in any group that grants the app.
- What means "this is your problem": the app is still assigned through another group, or deactivation removed sign-in but not the assignment/provisioning relationship.
- Jump to: Fixes → User was deactivated in Okta but app assignment/group removal did not propagate.
-
Check whether the app supports local auth outside Okta.
- Inspect app config/env and login UI for password, magic-link, backup admin, or legacy LDAP paths.
grep -E 'AUTH_|SAML|OIDC|LDAP|LOCAL_LOGIN|PASSWORD_LOGIN' .env config/* 2>/dev/null
- What means "this is your problem": local auth is enabled, or the user can still authenticate without going through Okta.
- Jump to: Fixes → App has a local account/password path independent of Okta.
- Search for duplicate downstream identities.
- Query by email and by external Id / subject claim.
SELECT id, email, external_id, status, auth_source, last_login_at
FROM users
WHERE email = 'alex@example.com' OR external_id = '00uabc123xyz';
- What means "this is your problem": more than one row, mismatched
external_id, or one local account plus one SSO account. - Jump to: Fixes → Multiple identity sources or duplicate user records exist downstream.
Fixes
Existing app session or JWT remains valid after Okta deactivation
Short version: deactivation stopped new federation, but your app still trusts already-issued sessions/tokens.
- Revoke app-side sessions for the user.
DELETE FROM sessions WHERE user_id = '8f2c1d2a';
UPDATE users SET force_reauth_at = NOW() WHERE id = '8f2c1d2a';
- If you use Redis-backed sessions:
redis-cli --scan --pattern 'sess:*8f2c1d2a*' | xargs -r redis-cli DEL
- Reduce token TTL and require refresh-token rotation or introspection for high-risk apps. Example app config:
{
"accessTokenTtlSeconds": 300,
"refreshTokenTtlSeconds": 28800,
"checkTokenRevocation": true
}
- If your API validates JWTs offline only, add a revocation gate keyed by
suborjti.
Verify it worked:
curl -i https://app.example.com/api/me -H "Authorization: Bearer $OLD_TOKEN"
Expected: 401 Unauthorized or 403 Forbidden.
SCIM provisioning/deprovisioning is not enabled or failed
- In the Okta app integration, open the provisioning settings and confirm outbound provisioning is enabled for the app and that deactivation/suspend actions are mapped.
- Test the SCIM endpoint manually.
curl -i https://app.example.com/scim/v2/ServiceProviderConfig \
-H "Authorization: Bearer $SCIM_TOKEN" \
-H "Accept: application/scim+json"
Expected shape:
HTTP/1.1 200 OK
Content-Type: application/scim+json
- Common failures:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token"
HTTP/1.1 404 Not Found
- Fix auth, base URL, or SCIM path. Then replay the failed deprovision by reassigning and unassigning, or manually patching the user inactive if your process allows.
curl -sS -X PATCH https://app.example.com/scim/v2/Users/2819 \
-H "Authorization: Bearer $SCIM_TOKEN" \
-H "Content-Type: application/scim+json" \
-d '{"schemas":["urn:ietf:params:scim:api:messages:2.0:PatchOp"],"Operations":[{"op":"Replace","path":"active","value":false}]}'
Verify it worked:
curl -s https://app.example.com/scim/v2/Users/2819 -H "Authorization: Bearer $SCIM_TOKEN" | jq '.active'
Expected: false.
User was deactivated in Okta but app assignment/group removal did not propagate
- Remove all direct and group-based assignments that grant the app. In Okta admin, inspect both the user's direct assignments and every group that maps to the app.
- If your app provisions on group membership, remove the user from the granting group and force a group push/provisioning sync from the app integration.
- If you automate via API, remove assignment and verify no remaining group grants exist before retrying deprovision.
Verify it worked: in Okta, the user should show no assignment path to the app, and the app admin/SCIM record should flip to inactive after the next sync.
App has a local account/password path independent of Okta
- Disable local login for workforce users in app config.
auth:
saml_enabled: true
oidc_enabled: false
local_password_login: false
break_glass_admins:
- ops-admin@example.com
- Invalidate any existing password reset tokens and disable the local account.
UPDATE users SET password_hash = NULL, local_login_enabled = false, status = 'disabled' WHERE email = 'alex@example.com';
DELETE FROM password_reset_tokens WHERE user_id = '8f2c1d2a';
⚠️ If you disable local auth globally, confirm you still have a tested break-glass admin path before applying the change.
Verify it worked:
curl -i -X POST https://app.example.com/login/password -d 'email=alex@example.com&password=...'
Expected: 403 or route disabled.
App maps Okta deactivation incorrectly and only disables new SSO, not existing app account
- Fix the deprovision handler so Okta deactivation maps to app account disable, not just assignment removal.
- If you consume SCIM, treat
active=falseas authoritative and block both UI and API access. Example pseudocode:
{
"if": "scim_user.active == false",
"then": ["disable_user", "revoke_sessions", "revoke_api_tokens"]
}
- Backfill affected users by querying all app users whose
external_idmatches deactivated IdP users and bulk-disabling them.
Verify it worked:
curl -s https://app.example.com/scim/v2/Users/2819 -H "Authorization: Bearer $SCIM_TOKEN" | jq '.active'
And app login/API requests should return 403.
Multiple identity sources or duplicate user records exist downstream
- Merge or disable the duplicate account. Prefer the account with the correct
external_idfrom Okta.
SELECT id, email, external_id, auth_source, status FROM users WHERE email='alex@example.com';
UPDATE users SET status='disabled' WHERE id='legacy-local-42';
UPDATE users SET external_id='00uabc123xyz', auth_source='okta' WHERE id='sso-2819';
- Add a uniqueness constraint if your schema allows it.
⚠️ Adding a unique index can fail or lock writes on large tables. Test in staging and use your database's online index option where available.
CREATE UNIQUE INDEX CONCURRENTLY users_email_auth_source_uq ON users (email, auth_source);
Verify it worked:
SELECT count(*) FROM users WHERE email='alex@example.com';
Expected: one active authoritative account path.
Prevention
- Add an automated offboarding probe that deactivates a test user weekly, then verifies app access is blocked within an SLA.
./deactivate-test-user.sh && ./assert-no-access.sh test.offboarded@example.com
- Alert on provisioning failures from your IdP/app integration logs. Ship SCIM 4xx/5xx to your log pipeline and page on sustained errors.
filter: service=scim AND status>=400
threshold: 5 errors / 10m
- Keep app session TTL short for workforce apps and enforce session revocation on user disable.
session_ttl_minutes: 15
revoke_sessions_on_user_disable: true
- Store and validate the IdP
sub/external ID, not just email, and add a duplicate-account check in CI migrations.
SELECT email, count(*) FROM users GROUP BY email HAVING count(*) > 1;
- Add a regression test for SCIM
active=falsehandling in the app.
pytest tests/scim/test_deprovision.py -k active_false_revokes_access
- Log auth source and subject on every successful login so incidents can distinguish SSO from local auth immediately.
{"event":"login_success","user":"alex@example.com","auth_source":"saml","sub":"00uabc123xyz"}
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