Guide · MCP Identity Provider Integration
MCP Server Okta — user lifecycle, group management, and SSO integration
Okta is the dominant enterprise identity provider, sitting in front of every SaaS application and internal tool in organizations worldwide. This guide covers building TypeScript MCP tools that interact with Okta's Management API: SSWS token authentication, fetching users by login or ID, filtering user lists with Okta's expression syntax, managing group membership, resetting passwords via lifecycle operations, suspending users with confirm guards, and wiring a /health/okta endpoint so AliveMCP detects API outages and token scope problems before your automation workflows fail silently.
TL;DR
Okta's Management API uses SSWS-prefixed API tokens in the Authorization header — not Bearer tokens. Rate limits are per-endpoint and per-token, not global: the /api/v1/users endpoint allows 100 req/min by default, with limits returned in X-Rate-Limit-Remaining and X-Rate-Limit-Reset response headers. Always check these headers before retrying. For user searches, use the filter parameter with SCIM-like expressions (profile.department eq "Engineering", status eq "ACTIVE") rather than the q search parameter — the filter expression is server-evaluated and returns exact matches. Wire AliveMCP to your /health/okta endpoint, which calls GET /api/v1/users?limit=1 — if the token is revoked or scope is insufficient, this fails immediately.
HTTP client setup and SSWS authentication
Okta's Management API uses a proprietary authentication scheme called SSWS (Simple Web Service Secure). API tokens are created in the Okta admin console under Security → API → Tokens. These are long-lived tokens tied to a specific admin user — they inherit that user's permissions and expire only when the user's session expires or the token is explicitly revoked.
import axios, { AxiosInstance, AxiosResponse } from 'axios';
const OKTA_DOMAIN = process.env.OKTA_DOMAIN!; // e.g. 'yourorg.okta.com'
const OKTA_API_TOKEN = process.env.OKTA_API_TOKEN!;
const oktaHttp: AxiosInstance = axios.create({
baseURL: `https://${OKTA_DOMAIN}/api/v1`,
headers: {
'Authorization': `SSWS ${OKTA_API_TOKEN}`, // note: SSWS, not Bearer
'Content-Type': 'application/json',
'Accept': 'application/json'
},
timeout: 15_000
});
// Rate limit tracking — Okta returns limits per endpoint
interface RateLimitState {
remaining: number;
resetAt: number; // Unix timestamp
}
const rateLimitCache = new Map();
// Intercept responses to track rate limits
oktaHttp.interceptors.response.use((response: AxiosResponse) => {
const remaining = parseInt(response.headers['x-rate-limit-remaining'] ?? '100');
const reset = parseInt(response.headers['x-rate-limit-reset'] ?? '0');
const endpoint = new URL(response.config.url!, `https://${OKTA_DOMAIN}`).pathname;
rateLimitCache.set(endpoint, { remaining, resetAt: reset * 1000 });
// Warn when getting close to limit
if (remaining < 10) {
console.warn(`[okta] Rate limit low on ${endpoint}: ${remaining} remaining, resets at ${new Date(reset * 1000).toISOString()}`);
}
return response;
});
| Okta endpoint | Default rate limit | Notes |
|---|---|---|
/api/v1/users |
100 req/min | List and search operations |
/api/v1/users/:id |
600 req/min | Individual user GET/PUT |
/api/v1/groups |
100 req/min | Group list operations |
/api/v1/users/:id/lifecycle/ |
50 req/min | Suspend, activate, deactivate |
/api/v1/users/:id/lifecycle/reset_password |
10 req/min | Password reset operations |
get_user and list_users tools
Okta allows fetching a user by their Okta ID (a 20-character opaque ID like 00u1abc...) or by their login attribute (email address in most configurations). The list_users tool uses Okta's filter parameter, which supports a SCIM-like expression language for server-side filtering — more reliable than client-side filtering of paginated results.
import { z } from 'zod';
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
function formatUser(u: any) {
return {
id: u.id,
login: u.profile?.login,
email: u.profile?.email,
first_name: u.profile?.firstName,
last_name: u.profile?.lastName,
department: u.profile?.department,
title: u.profile?.title,
status: u.status,
created: u.created,
last_login: u.lastLogin,
password_changed: u.passwordChanged
};
}
server.tool(
'get_okta_user',
{
// Accept either Okta user ID (00u...) or login (email)
user_id_or_login: z.string().min(1)
},
async ({ user_id_or_login }) => {
try {
const res = await oktaHttp.get(`/users/${encodeURIComponent(user_id_or_login)}`);
return {
content: [{ type: 'text', text: JSON.stringify(formatUser(res.data), null, 2) }]
};
} catch (err: any) {
if (err.response?.status === 404) {
throw new McpError(ErrorCode.InvalidParams, `User not found: ${user_id_or_login}`);
}
throw err;
}
}
);
server.tool(
'list_okta_users',
{
filter: z.string().optional(), // e.g. 'status eq "ACTIVE"' or 'profile.department eq "Engineering"'
q: z.string().optional(), // free-text search across profile fields
limit: z.number().int().min(1).max(200).default(50)
},
async ({ filter, q, limit }) => {
const params: Record = { limit };
if (filter) params.filter = filter;
if (q) params.q = q;
const users: any[] = [];
let nextUrl: string | null = `/users`;
while (nextUrl && users.length < limit) {
const res = await oktaHttp.get(nextUrl, { params: users.length === 0 ? params : {} });
users.push(...(res.data as any[]).map(formatUser));
// Okta paginates via Link header: rel="next"
const linkHeader = res.headers['link'] ?? '';
const nextMatch = linkHeader.match(/<([^>]+)>;\s*rel="next"/);
nextUrl = nextMatch ? nextMatch[1].replace(`https://${OKTA_DOMAIN}/api/v1`, '') : null;
}
return {
content: [{ type: 'text', text: JSON.stringify({ count: users.length, users: users.slice(0, limit) }, null, 2) }]
};
}
);
Common Okta filter expressions:
// Users in a specific department
'profile.department eq "Engineering"'
// Active users only
'status eq "ACTIVE"'
// Users who haven't logged in since a date
'lastLogin lt "2026-01-01T00:00:00.000Z"'
// Combine filters with 'and'
'status eq "ACTIVE" and profile.department eq "Sales"'
// Users with a specific job title
'profile.title sw "Senior"' // sw = starts with
Group management tools
server.tool(
'list_okta_groups',
{
q: z.string().optional(), // filter groups by name
limit: z.number().int().min(1).max(200).default(50)
},
async ({ q, limit }) => {
const params: Record = { limit };
if (q) params.q = q;
const res = await oktaHttp.get('/groups', { params });
const groups = (res.data as any[]).map(g => ({
id: g.id,
name: g.profile?.name,
description: g.profile?.description,
type: g.type, // 'OKTA_GROUP', 'APP_GROUP', or 'BUILT_IN'
member_count: g.objectClass
}));
return {
content: [{ type: 'text', text: JSON.stringify({ count: groups.length, groups }, null, 2) }]
};
}
);
server.tool(
'get_okta_group_members',
{
group_id: z.string().min(1),
limit: z.number().int().min(1).max(1000).default(100)
},
async ({ group_id, limit }) => {
const res = await oktaHttp.get(`/groups/${group_id}/users`, { params: { limit } });
const members = (res.data as any[]).map(formatUser);
return {
content: [{ type: 'text', text: JSON.stringify({ group_id, count: members.length, members }, null, 2) }]
};
}
);
User lifecycle: reset_password and suspend_user tools
server.tool(
'reset_okta_password',
{
user_id: z.string().min(1),
send_email: z.boolean().default(true) // false returns temp password in response
},
async ({ user_id, send_email }) => {
try {
const res = await oktaHttp.post(
`/users/${user_id}/lifecycle/reset_password`,
{},
{ params: { sendEmail: send_email } }
);
if (send_email) {
return {
content: [{ type: 'text', text: JSON.stringify({
user_id,
action: 'password_reset_email_sent',
note: 'User will receive an email with a one-time reset link'
}, null, 2) }]
};
}
// When sendEmail=false, Okta returns a one-time temp password URL
return {
content: [{ type: 'text', text: JSON.stringify({
user_id,
action: 'password_reset',
reset_password_url: res.data?.resetPasswordUrl,
warning: 'This URL is a one-time link and will expire. Share it via a secure channel.'
}, null, 2) }]
};
} catch (err: any) {
if (err.response?.status === 403) {
throw new McpError(ErrorCode.InvalidRequest,
'Insufficient token scope: token must have okta.users.manage permission');
}
throw err;
}
}
);
server.tool(
'suspend_okta_user',
{
user_id: z.string().min(1),
confirm: z.literal(true) // confirm guard — suspension blocks all Okta SSO access
},
async ({ user_id }) => {
try {
await oktaHttp.post(`/users/${user_id}/lifecycle/suspend`);
return {
content: [{ type: 'text', text: JSON.stringify({
user_id,
action: 'suspended',
effect: 'User cannot sign into any Okta-connected applications',
undo: 'Call unsuspend to restore access'
}, null, 2) }]
};
} catch (err: any) {
if (err.response?.status === 400 && err.response.data?.errorCode === 'E0000001') {
throw new McpError(ErrorCode.InvalidParams,
`Cannot suspend user ${user_id}: user may already be suspended or in a non-active state`);
}
throw err;
}
}
);
Health endpoint: /health/okta
import express from 'express';
const app = express();
app.get('/health/okta', async (_req, res) => {
const start = Date.now();
try {
// GET /api/v1/users?limit=1 — validates token scope without expensive operations
const oktaRes = await oktaHttp.get('/users', { params: { limit: 1 } });
const remaining = parseInt(oktaRes.headers['x-rate-limit-remaining'] ?? '100');
const resetAt = parseInt(oktaRes.headers['x-rate-limit-reset'] ?? '0');
res.status(200).json({
status: 'ok',
okta_domain: OKTA_DOMAIN,
rate_limit_remaining: remaining,
rate_limit_resets_at: new Date(resetAt * 1000).toISOString(),
latency_ms: Date.now() - start
});
} catch (err: any) {
const httpStatus = err.response?.status;
res.status(503).json({
status: 'error',
okta_error_code: err.response?.data?.errorCode,
okta_error: err.response?.data?.errorSummary,
http_status: httpStatus,
latency_ms: Date.now() - start
});
}
});
app.listen(3001);
| Okta error code | Meaning | Action |
|---|---|---|
E0000011 |
Invalid token | Token was revoked or never existed — rotate the API token |
E0000004 |
Authentication failed | Malformed SSWS header — check token format |
E0000006 |
Access denied | Token lacks required scopes for the operation |
| HTTP 429 | Rate limit exceeded | Wait until X-Rate-Limit-Reset timestamp |
Frequently asked questions
Should I use Okta's SSWS API tokens or OAuth 2.0 service apps?
For production MCP servers, prefer OAuth 2.0 service apps (client_credentials grant) over SSWS tokens. Service apps use short-lived JWTs rather than long-lived secrets, have granular API scopes (e.g. okta.users.read vs okta.users.manage), and can be rotated without going through the admin console. SSWS tokens inherit the full permissions of the admin user who created them — a compromised SSWS token has admin-level access to your entire Okta org. Use SSWS tokens only for development or when setting up OAuth 2.0 is not feasible.
How do I handle Okta pagination correctly?
Okta returns pagination cursors via Link response headers, not in the JSON body. Look for <url>; rel="next" in the header — the full next-page URL is already constructed. Do not try to build next-page URLs manually by incrementing a page offset; Okta uses opaque after-cursors. The last page either has no Link: rel="next" header or returns an empty array. Always follow the cursor from the response rather than constructing your own.
What's the difference between deactivate, suspend, and delete in Okta?
Suspend: User cannot sign in or access Okta apps. Account and data preserved. Reversible via unsuspend. Best for temporary blocks (e.g., investigate suspicious activity). Deactivate: User is placed in DEPROVISIONED state. Okta pushes deactivation to all downstream app integrations that support it. Reversible by reactivating. Best for departing employees. Delete: Permanently removes the user and all their data from Okta. Irreversible. Typically only done for test accounts or GDPR erasure requests. For most offboarding workflows, use deactivate — it triggers downstream deprovisioning automatically.
How do I filter users by custom profile attributes?
Custom attributes added to the Okta user profile schema are queryable via profile.attributeName eq "value". The attribute name must match the API name in your schema (not the display name). Find the API name in Okta admin console under Directory → Profile Editor → User (default) → attribute settings. Not all custom attribute types support all filter operators: string attributes support eq, sw, co; date attributes support lt, gt. Complex types (arrays) are not filterable.
Further reading
- MCP Server Auth0 — M2M tokens, user search, and role assignment
- MCP Server AWS IAM — assume roles and simulate policies
- MCP Server Authentication — token management and security patterns
- MCP Server Health Check — wiring /health endpoints for uptime monitoring
- MCP Server Error Handling — mapping HTTP errors to McpError codes