Skip to content
Skills Library
Programming · Building & coding

api-auth

/api-auth

Authentication, authorization, and multi-tenancy patterns for `@cyanheads/mcp-ts-core`. Use when implementing auth scopes on tools/resources, configuring auth modes (none/jwt/oauth), working with JWT/OAuth env vars, or understanding how tenantId flows through ctx.state.

Licence
Apache-2.0
Last repository change
17.10.2026

Assessment and security

100/ 100Class: good
Format25 / 25
Frontmatter with name and description, valid name, description up to 1,024 characters, name matching the folder.
Discoverability25 / 25
Description explains when to use the skill, has a reasonable length and uses third person.
Content35 / 35
At least 150 words and at most 500 lines, headings, step lists, examples or code.
Hygiene15 / 15
No placeholders such as TODO, no Windows paths, with a licence.
Riskno signals detected in the scan
Files in this package
1
Scripts
0
HTML comments
0
URLs in SKILL.md
0
Deprecation

No signals detected. Repository changed on 17.10.2026, without references to retired models or APIs.

Licence

Apache-2.0, otwarta: można używać i zmieniać, z zachowaniem licencji i informacji o zmianach.

What it does and when to use it

Authentication, authorization, and multi-tenancy patterns for `@cyanheads/mcp-ts-core`. Use when implementing auth scopes on tools/resources, configuring auth modes (none/jwt/oauth), working with JWT/OAuth env vars, or understanding how tenantId flows through ctx.state.

Original author description

Authentication, authorization, and multi-tenancy patterns for `@cyanheads/mcp-ts-core`. Use when implementing auth scopes on tools/resources, configuring auth modes (none/jwt/oauth), working with JWT/OAuth env vars, or understanding how tenantId flows through ctx.state.

How it works

  1. Overview
  2. Inline auth (primary pattern)
  3. Dynamic auth
  4. Auth modes
  5. Endpoints
  6. Multi-tenancy
  7. Auth context shape
Original SKILL.md headings · 207 lines, 1,563 words
SKILL.md preview
---
name: api-auth
description: >
  Authentication, authorization, and multi-tenancy patterns for `@cyanheads/mcp-ts-core`. Use when implementing auth scopes on tools/resources, configuring auth modes (none/jwt/oauth), working with JWT/OAuth env vars, or understanding how tenantId flows through ctx.state.
metadata:
  author: cyanheads
  version: "1.6"
  audience: external
  type: reference
---

## Overview

The framework handles auth at the handler factory level — tools and resources declare required scopes declaratively, and the framework enforces them before calling the handler. No try/catch or manual scope checking required for the common case.

---

## Inline auth (primary pattern)

Declare required scopes directly on the tool or resource definition via the `auth` property. The handler factory checks `ctx.auth.scopes` against these before calling `handler`.

```ts
import { tool } from '@cyanheads/mcp-ts-core';

const myTool = tool('my_tool', {
  input: z.object({ query: z.string().describe('Search query') }),
  output: z.object({ result: z.string().describe('Search result') }),
  auth: ['tool:my_tool:read'],
  async handler(input, ctx) {
    // Only reached if caller has 'tool:my_tool:read' scope
  },
});
```

When `MCP_AUTH_MODE=none`, auth checks are skipped and defaults are allowed.

A failed check returns `Forbidden` (-32005, `Insufficient permissions.`) or, when auth is enabled but the request carries no auth context, `Unauthorized` (-32006). Neither carries `data`: the required, granted, and missing scope names stay in the server log, so a caller cannot enumerate scopes from the error.

The scope check logs a missing scope at `warning`, naming the missing scopes. On a tool, the call's `Error in tool:<name>` record follows at `notice` with no stack — the refusal is the caller's standing, not a fault in this server — and `mcp.errors.classified` counts it with `mcp.error.severity: "notice"`. That holds for the inline check and for `checkScopes` in a handler alike, and the level is fixed: the refusal carries no `data.reason`, so no `errors[]` entry can set it. A missing auth context keeps its `Error in tool:` record at `error`, and so does a `forbidden()` the handler throws itself. A resource read the inline check refuses writes only the scope check's own records.

---

## Dynamic auth

For runtime-computed scopes (e.g., scopes that depend on input values like a team or resource ID), use `checkScopes` from `@cyanheads/mcp-ts-core/auth` inside the handler:

```ts
import { checkScopes } from '@cyanheads/mcp-ts-core/auth';

handler: async (input, ctx) => {
  checkScopes(ctx, [`team:${input.teamId}:write`]);
  // Continues only if scope is satisfied
},
```

**Signature:** `checkScopes(ctx: Context, requiredScopes: string[]): void`

**Throws:**

- `McpError(Forbidden)` — auth is active and one or more required scopes are missing
- `McpError(Unauthorized)` — auth is enabled but no auth context exists on the request
- No-ops when `MCP_AUTH_MODE=none`

---

## Auth modes

Set via `MCP_AUTH_MODE` environment variable.

| Mode | Value | Behavior |
|:-----|:------|:---------|
| Disabled | `none` | No auth enforcement. All requests allowed. |
| JWT | `jwt` | Local secret verification via `MCP_AUTH_SECRET_KEY`. Requires explicit `DEV_MCP_AUTH_BYPASS=true` to bypass in development. |
| OAuth | `oauth` | JWKS verification against an external issuer. |

### JWT config

| Variable | Required | Purpose |
|:---------|:---------|:--------|
| `MCP_AUTH_SECRET_KEY` | Yes (unless bypass) | Signing secret for HS256 JWT verification. Must be ≥ 32 characters. |
| `DEV_MCP_AUTH_BYPASS` | No | Set to `true` to skip JWT verification in development. Blocked in `NODE_ENV=production`. |
| `DEV_MCP_CLIENT_ID` | No | Client ID injected when bypass is active (default: `'dev-client-id'`). |
| `DEV_MCP_SCOPES` | No | Comma-separated scopes injected when bypass is active (default: `['dev-scope']`). |

**Important:** With `MCP_AUTH_MODE=jwt`, a missing `MCP_AUTH_SECRET_KEY` is a **fatal startup error** unless `DEV_MCP_AUTH_BYPASS=true` is explicitly set. Setting `DEV_MCP_AUTH_BYPASS` in production (`NODE_ENV=production`) is rejected at config parse time.

### OAuth config

| Variable | Required | Purpose |
|:---------|:---------|:--------|
| `OAUTH_ISSUER_URL` | Yes | Token issuer URL (used for JWKS discovery) |
| `OAUTH_AUDIENCE` | Yes | Expected `aud` claim value |
| `OAUTH_JWKS_URI` | No | Override JWKS endpoint (defaults to `{issuer}/.well-known/jwks.json`) |
| `MCP_SERVER_RESOURCE_IDENTIFIER` | No | RFC 8707 resource indicator URI. When set, the OAuth strategy validates that the token's `resource` or `aud` claim matches this value — throws `Forbidden` on mismatch. |

### JWT claims mapping

| Claim | JWT Field | Purpose |
|:------|:----------|:--------|
| `clientId` | `cid` / `client_id` | Identifies the calling client |
| `scopes` | union of `scp`, `scope`, `mcp_tool_scopes` | Granted scope list (see below) |
| `sub` | `sub` | Subject (user or service identity) |
| `tenantId` | `tid` | Tenant identifier — drives `ctx.state` scoping |

`scopes` is the **union** of three claims, in this order:

| Claim | Form | Source |
|:------|:-----|:-------|
| `scp` | array of strings | Okta-style |
| `scope` | space-delimited string | OAuth 2.1 / OIDC standard |
| `mcp_tool_scopes` | array of strings **or** space-delimited string | Custom claim for OIDC providers that cannot inject scopes into `scope` during the `authorization_code` flow (Authentik, Keycloak < 26.5, Zitadel) |

Auth0/Okta-style providers that already populate `scp` or `scope` need no migration. Other deployments add a property mapping returning `{"mcp_tool_scopes": "tool:foo:read tool:bar:write"}` — the framework unions it into `ctx.auth.scopes` alongside the standard claims. Hardcoded claim name; deployments whose IdP cannot emit `mcp_tool_scopes` use the bypass flag below.

### OIDC operator setup (Authentik / Keycloak / Zitadel)

Standard OIDC providers compute the JWT `scope` claim from what the OAuth client requested at the authorization endpoint and ignore property mappings that try to override `scope` in the `authorization_code` flow. Property mappings that inject **other** claim names work fine. To grant per-tool scopes to a Claude.ai or ChatGPT custom connector that doesn't expose scope customization, configure your IdP to return the per-tool scopes under `mcp_tool_scopes` instead of overriding `scope`.

| Provider | Where to configure |
|:---------|:--------------------|
| Authentik | Customization → Property Mappings → new "Scope Mapping" returning `{"mcp_tool_scopes": "tool:foo:read tool:bar:write"}`; bind to the OAuth2/OpenID provider |
| Keycloak (< 26.5) | Client → Client Scopes → Mappers → new "Hardcoded claim" or "Script Mapper" emitting `mcp_tool_scopes` |
| Zitadel | Project → Roles + Action returning `{"mcp_tool_scopes": "..."}` from a pre-token script |

Keycloak ≥ 26.5 ships native MCP integration support; check its release notes before falling back to a custom claim.

### Bypass flag

For environments where no custom claim can be injected (managed services, restricted IdPs), set `MCP_AUTH_DISABLE_SCOPE_CHECKS=true` to bypass scope enforcement entirely.

| Variable | Default | Effect |
|:---------|:--------|:-------|
| `MCP_AUTH_DISABLE_SCOPE_CHECKS` | `false` | When `true`, both `withRequiredScopes` (declared `auth: [...]`) and `checkScopes` (runtime-computed scopes inside handlers) early-return after the auth-context presence check. Token signature, audience, issuer, and expiry validation remain intact. |

The flag bypasses **both** declared `auth: [...]` enforcement and runtime `checkScopes` calls — including tenant isolation patterns like `team:${input.teamId}:write`. Naming is deliberate: this disables all scope checks, not just per-tool ones. Applies to `MCP_AUTH_MODE=jwt` and `MCP_AUTH_MODE=oauth` (no effect under `none`).

A `WARNING`-level log is emitted at startup whenever the flag is active so operators don't lose track of it. Combine with server-side ACLs (path filters, allowlists, tenant rules) — without an in-handler ACL, every authenticated user effectively has every scope.

---

## Endpoints

| Endpoint | Protected |
|:---------|:----------|
| `GET /healthz` | No |
| `GET /mcp` | No |
| `POST /mcp` | Yes (when auth enabled) |
| `DELETE /mcp` | Yes (when auth enabled) — session termination |
| `OPTIONS /mcp` | No (handled by CORS middleware before auth) |

**CORS:** Set `MCP_ALLOWED_ORIGINS` to a comma-separated list of allowed origins, or `*` for open access. Left unset, only loopback browser origins reach the endpoint. The preflight for an accepted origin allows every request header the server reads: `Content-Type`, `Authorization`, `Mcp-Session-Id`, `MCP-Protocol-Version`, the 2026-07-28 `Mcp-Method` and `Mcp-Name`, `Last-Event-ID` (SSE resume), and one `Mcp-Param-<Name>` per `headerParam` designation on a registered tool — derived from the tool definitions, nothing to configure. Any other origin gets the first four only, so it learns no designation names.

**Stdio mode:** No HTTP auth layer. Authorization is handled entirely by the host process.

---

## Multi-tenancy

`ctx.state` is automatically scoped to the current tenant — no manual key prefixing needed.

### tenantId sources

| Mode | Source | Value |
|:-----|:-------|:------|
| Stdio (any auth mode) | Hardcoded default | `'default'` |
| HTTP + `MCP_AUTH_MODE=none` | Hardcoded default | `'default'` (single-tenant by design) |
| HTTP + `MCP_AUTH_MODE=jwt`/`oauth` | JWT `tid` claim | Auto-propagated from token; `undefined` if absent (fail-closed) |

### Tenant ID validation rules

- Max 128 characters
- Characters: alphanumeric, hyphens, underscores, dots
- Must start and end with an alphanumeric character
- No path traversal sequences (`../`)
- No consecutive dots (`..`)

### Using `ctx.state`

```ts
handler: async (input, ctx) => {
  // Automatically scoped to ctx.tenantId — no manual prefixing
  await ctx.state.set('item/123', { name: 'Widget', count: 42 });
  const item = await ctx.state.get<Item>('item/123');
  await ctx.state.delete('item/123');

  const page = await ctx.state.list('item/', { cursor, limit: 20 });
  // page: { items: Array<{ key, value }>, cursor?: string }
},
```

`ctx.state` throws `McpError(InvalidRequest)` if `tenantId` is missing. Stdio (any auth mode) and HTTP+`MCP_AUTH_MODE=none` default `tenantId` to `'default'` so `ctx.state` works without forcing operators to mint tokens. HTTP+`jwt`/`oauth` deliberately fails closed when the token lacks a `tid` claim — distinct authenticated callers must not silently share state.

---

## Auth context shape

Available on `ctx.auth` inside handlers (when auth is enabled):

```ts
interface AuthContext {
  clientId: string;        // Required — 'cid' or 'client_id' JWT claim
  scopes: string[];        // Required — union of 'scp', 'scope', and 'mcp_tool_scopes' claims
  sub: string;             // Required — 'sub' claim; falls back to clientId when absent
  token?: string;          // Optional — raw JWT or OAuth bearer token string (present when transport provides it)
  tenantId?: string;       // Optional — 'tid' claim; present only for multi-tenant tokens
}
```

Access directly for conditional logic:

```ts
handler: async (input, ctx) => {
  const isAdmin = ctx.auth?.scopes.includes('admin:write') ?? false;
  // ...
},
```

Versions

RepositoryScoreLicence
This page · cyanheads/ntfy-mcp-server100Apache-2.0
cyanheads/clinicaltrialsgov-mcp-server100Apache-2.0
cyanheads/mcp-ts-core100Apache-2.0

Identical content appears in 8 other GitHub folders. We show one copy with the highest score.

Database snapshot: 13.11.2026 · package version 26982a1a456f (SHA-256 digest of the entire package)

Downloads in the last 30 days: 0 · ranking

Report a problem with this skill: harmful content, incorrect descriptions or an author’s removal request.

Similar skills

  • 100auth-security
    OAuth 2.1 + JWT authentication security best practices. Use when implementing auth, API authorization, token management. Follows RFC 9700 (2025).
  • 100auth-system-setup
    When the user wants to set up authentication and authorization for a web application. Use when the user mentions "auth," "login," "OAuth," "SSO," "single sign-on," "role-based access," "RBAC," "permissions," "user roles," "access control," "authentication," or "authorization."…
  • 100auth-and-api-keys
    This skill should be used when the user is designing API authentication, implementing API keys with prefixes, choosing between API keys and OAuth, setting up JWT tokens, implementing Bearer token authentication, designing API key rotation, or scoping API key permissions. Covers…
  • 100auth-patterns
    JWT access/refresh tokens, Passport.js strategies, session management, OAuth. Use when implementing authentication, authorization, or setting up OAuth providers in a NestJS + Next.js app.
  • 100auth-patterns
    This skill should be used when the user asks about "authentication in Next.js", "NextAuth", "Auth.js", "middleware auth", "protected routes", "session management", "JWT", "login flow", or needs guidance on implementing authentication and authorization in Next.js applications.
  • 100agent-auth-specialist
    Specialist subagent: OAuth 2.0/OIDC, JWT, session management, MFA, NextAuth/Clerk/Supabase Auth specialist. Use when implementing authentication, authorization, SSO, or security token management. Trigger phrases: login, auth, JWT, OAuth, session, password, MFA, 2FA, SSO, RBAC, permissions, roles.

Feedback and reports

Your message goes to Bartosz Sokoliński. An email address is needed only for a reply. See the privacy policy.