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

Know when your Okta API token is revoked before it takes down your automation

AliveMCP monitors your /health/okta endpoint and alerts you the moment the token is invalidated, rate limits spike, or the Okta API becomes unreachable.

Start monitoring free