Guide · MCP Auth0 Management API Integration
MCP Server Auth0 — M2M tokens, user search, and role assignment
Auth0 is the most widely deployed identity platform for SaaS products, handling authentication for millions of applications. This guide covers building TypeScript MCP tools that interact with Auth0's Management API v2: obtaining Machine-to-Machine (M2M) tokens via the client_credentials grant with 60-second pre-expiry refresh, searching users with Lucene query syntax, updating user metadata with merge semantics (not replace), assigning and removing roles, blocking users with confirm guards, retrieving audit log events, and wiring a /health/auth0 endpoint so AliveMCP detects tenant rate limits and token expiry before your integration silently stops working.
TL;DR
Auth0 Management API tokens are short-lived JWTs from the client_credentials grant — cache them and refresh 60 seconds before expiry. The Management API rate limit is 15 req/second burst with a 30 req/min limit on the token endpoint; the X-RateLimit-Remaining and X-RateLimit-Reset headers are authoritative. User search uses Lucene syntax (email:"alice@example.com", name:Alice*) — do not use the older q parameter with the search_engine: v3 endpoint without wrapping strings in double quotes. User metadata updates use merge semantics: pass only the keys you want to change in user_metadata — Auth0 merges them. Wire AliveMCP to GET /api/v2/tenants/settings — it requires read:tenant_settings scope and validates both the token and tenant availability.
M2M token setup with automatic refresh
Auth0 Management API tokens expire — the default TTL is 86400 seconds (24 hours), but tenant configurations vary. Never hardcode an Auth0 Management API token. Instead, fetch a fresh token from the token endpoint and cache it, refreshing 60 seconds before expiry.
import axios, { AxiosInstance } from 'axios';
const AUTH0_DOMAIN = process.env.AUTH0_DOMAIN!; // e.g. 'yourorg.us.auth0.com'
const AUTH0_CLIENT_ID = process.env.AUTH0_CLIENT_ID!;
const AUTH0_CLIENT_SECRET = process.env.AUTH0_CLIENT_SECRET!;
const AUTH0_AUDIENCE = `https://${AUTH0_DOMAIN}/api/v2/`;
interface M2MToken {
accessToken: string;
expiresAt: number; // Date.now() + expires_in * 1000
}
let cachedToken: M2MToken | null = null;
async function getM2MToken(): Promise {
// Return cached token if valid for at least 60 more seconds
if (cachedToken && cachedToken.expiresAt - Date.now() > 60_000) {
return cachedToken.accessToken;
}
// Token endpoint rate limit: 30 req/min — only call when truly needed
const res = await axios.post(`https://${AUTH0_DOMAIN}/oauth/token`, {
grant_type: 'client_credentials',
client_id: AUTH0_CLIENT_ID,
client_secret: AUTH0_CLIENT_SECRET,
audience: AUTH0_AUDIENCE
}, {
headers: { 'Content-Type': 'application/json' }
});
cachedToken = {
accessToken: res.data.access_token,
expiresAt: Date.now() + res.data.expires_in * 1000
};
return cachedToken.accessToken;
}
async function getAuth0Http(): Promise {
const token = await getM2MToken();
return axios.create({
baseURL: `https://${AUTH0_DOMAIN}/api/v2`,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
timeout: 15_000
});
}
| Auth0 Management API limit | Value | Notes |
|---|---|---|
| Burst rate limit | 15 req/sec | Short bursts allowed; sustained traffic gets throttled |
| Token endpoint | 30 req/min | Separate limit — caching is essential |
| User search | 5 req/sec | Lower limit on /users endpoint specifically |
| Rate limit headers | X-RateLimit-Remaining |
Check before retrying; also X-RateLimit-Reset (epoch) |
get_user and search_users tools
Auth0 user search uses Lucene query syntax with the q parameter. String values must be double-quoted; wildcards use *; field names match Auth0's user model fields. Use search_engine: v3 for all new searches — v1 and v2 are deprecated.
import { z } from 'zod';
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
function formatUser(u: any) {
return {
user_id: u.user_id,
email: u.email,
name: u.name,
nickname: u.nickname,
picture: u.picture,
created_at: u.created_at,
updated_at: u.updated_at,
last_login: u.last_login,
login_count: u.logins_count,
email_verified: u.email_verified,
blocked: u.blocked ?? false,
user_metadata: u.user_metadata,
app_metadata: u.app_metadata
};
}
server.tool(
'get_auth0_user',
{
user_id: z.string().min(1) // e.g. 'auth0|64f...' or 'google-oauth2|1234...'
},
async ({ user_id }) => {
const http = await getAuth0Http();
try {
const res = await http.get(`/users/${encodeURIComponent(user_id)}`);
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, `Auth0 user not found: ${user_id}`);
}
throw err;
}
}
);
server.tool(
'search_auth0_users',
{
// Lucene query: email:"alice@example.com" or name:Alice* or logins_count:[5 TO *]
q: z.string().min(1),
per_page: z.number().int().min(1).max(100).default(25),
page: z.number().int().min(0).default(0)
},
async ({ q, per_page, page }) => {
const http = await getAuth0Http();
try {
const res = await http.get('/users', {
params: { q, search_engine: 'v3', per_page, page, include_totals: true }
});
return {
content: [{
type: 'text',
text: JSON.stringify({
total: res.data.total,
page,
per_page,
users: (res.data.users ?? res.data).map(formatUser)
}, null, 2)
}]
};
} catch (err: any) {
if (err.response?.status === 400) {
throw new McpError(ErrorCode.InvalidParams,
`Invalid Lucene query: ${err.response.data?.message ?? 'syntax error'}`);
}
throw err;
}
}
);
Useful Lucene query examples:
// Exact email match (always quote email values)
q: 'email:"alice@example.com"'
// Name starts with (wildcard must be at end for performance)
q: 'name:Alice*'
// Users who have logged in more than 10 times
q: 'logins_count:[10 TO *]'
// Users created after a date (ISO 8601)
q: 'created_at:[2026-01-01 TO *]'
// Users with a specific app_metadata field
q: 'app_metadata.plan:"enterprise"'
// Blocked users
q: 'blocked:true'
update_user_metadata tool (merge semantics)
Auth0 has two metadata buckets: user_metadata (user-editable; use for preferences, UI settings) and app_metadata (system-controlled; use for roles, plan tier, flags). The Management API PATCH operation merges metadata — only keys you pass are updated. To delete a metadata key, set it to null.
server.tool(
'update_auth0_user_metadata',
{
user_id: z.string().min(1),
user_metadata: z.record(z.unknown()).optional(),
app_metadata: z.record(z.unknown()).optional()
},
async ({ user_id, user_metadata, app_metadata }) => {
if (!user_metadata && !app_metadata) {
throw new McpError(ErrorCode.InvalidParams,
'Must provide at least one of user_metadata or app_metadata');
}
const http = await getAuth0Http();
const patch: Record = {};
if (user_metadata) patch.user_metadata = user_metadata;
if (app_metadata) patch.app_metadata = app_metadata;
const res = await http.patch(`/users/${encodeURIComponent(user_id)}`, patch);
return {
content: [{
type: 'text',
text: JSON.stringify({
user_id: res.data.user_id,
user_metadata: res.data.user_metadata,
app_metadata: res.data.app_metadata,
updated_at: res.data.updated_at
}, null, 2)
}]
};
}
);
assign_role and block_user tools
server.tool(
'assign_auth0_role',
{
user_id: z.string().min(1),
role_ids: z.array(z.string()).min(1).max(20)
},
async ({ user_id, role_ids }) => {
const http = await getAuth0Http();
// POST /users/:id/roles — assigns roles; does not replace existing roles
await http.post(`/users/${encodeURIComponent(user_id)}/roles`, { roles: role_ids });
return {
content: [{ type: 'text', text: JSON.stringify({
user_id,
roles_added: role_ids,
note: 'Roles are additive — existing roles are not removed'
}, null, 2) }]
};
}
);
server.tool(
'remove_auth0_role',
{
user_id: z.string().min(1),
role_ids: z.array(z.string()).min(1).max(20),
confirm: z.literal(true)
},
async ({ user_id, role_ids }) => {
const http = await getAuth0Http();
await http.delete(`/users/${encodeURIComponent(user_id)}/roles`, { data: { roles: role_ids } });
return {
content: [{ type: 'text', text: JSON.stringify({ user_id, roles_removed: role_ids }, null, 2) }]
};
}
);
server.tool(
'block_auth0_user',
{
user_id: z.string().min(1),
confirm: z.literal(true) // block immediately revokes all active sessions
},
async ({ user_id }) => {
const http = await getAuth0Http();
await http.patch(`/users/${encodeURIComponent(user_id)}`, { blocked: true });
// Optionally revoke all existing refresh tokens
await http.delete(`/users/${encodeURIComponent(user_id)}/refresh_tokens`).catch(() => {});
return {
content: [{ type: 'text', text: JSON.stringify({
user_id,
blocked: true,
sessions_invalidated: true,
note: 'User cannot obtain new tokens. Existing JWTs remain valid until their exp.'
}, null, 2) }]
};
}
);
Health endpoint: /health/auth0
import express from 'express';
const app = express();
app.get('/health/auth0', async (_req, res) => {
const start = Date.now();
try {
const http = await getAuth0Http();
// GET /api/v2/tenants/settings — lightweight check requiring read:tenant_settings scope
const settingsRes = await http.get('/tenants/settings', {
params: { fields: 'friendly_name,default_directory' }
});
res.status(200).json({
status: 'ok',
tenant: settingsRes.data.friendly_name,
auth0_domain: AUTH0_DOMAIN,
token_expires_at: cachedToken ? new Date(cachedToken.expiresAt).toISOString() : null,
latency_ms: Date.now() - start
});
} catch (err: any) {
const httpStatus = err.response?.status;
// 401 = token expired or revoked; 403 = insufficient scopes
res.status(httpStatus === 429 ? 503 : httpStatus ?? 503).json({
status: 'error',
auth0_error: err.response?.data?.error,
message: err.response?.data?.message ?? err.message,
http_status: httpStatus,
latency_ms: Date.now() - start
});
}
});
app.listen(3001);
Frequently asked questions
What scopes does the M2M application need?
Create a dedicated Machine-to-Machine application in the Auth0 dashboard and grant it only the scopes your MCP server needs. Minimum for read-only operations: read:users, read:user_idp_tokens, read:roles. For lifecycle management: add update:users, update:users_app_metadata, create:user_tickets. For role assignment: update:users + read:roles. For blocking: update:users. For the health check: read:tenant_settings. Principle of least privilege — never grant manage:users unless you specifically need it.
How do I list a user's log events for audit purposes?
Call GET /api/v2/users/:id/logs — requires read:logs scope. Each log event has a type code (e.g. s = success login, f = failed login, sbl = successful brute-force block lift). Logs are retained for 30 days on paid plans, 2 days on free plans. For compliance use cases, set up a Log Streaming integration to forward events to your SIEM in real-time rather than pulling via API.
Why does user metadata update fail with "Payload validation error"?
Auth0 enforces schema restrictions on app_metadata. Reserved top-level fields — _id, blocked, clientID, email, email_verified, globalClientID, global_client_id, user_id, identities, lastIP, lastLogin, metadata, logins_count, name, nickname, picture, updated_at, username — cannot be set in metadata. Namespace your custom fields under a domain-based key ("https://yourapp.com/metadata") to avoid conflicts with Auth0's reserved namespace.
What happens to existing JWTs when I block a user?
Blocking a user prevents them from obtaining new tokens but does not immediately invalidate JWTs that have already been issued — those remain valid until their exp claim expires. To immediately invalidate all existing tokens, use refresh token revocation (DELETE /api/v2/users/:id/refresh_tokens), but note this only works if your application uses refresh tokens. For JWTs without refresh tokens, the fastest mitigation is to reduce your access token TTL in tenant settings — tokens expire sooner in future. For high-security scenarios, implement token introspection or use Auth0 Actions to check the blocked flag on every authorization event.
Further reading
- MCP Server Okta — SSWS tokens, user lifecycle, and group management
- MCP Server Open Policy Agent — evaluate policies and query data layers
- 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