Skip to main content

TypeScript SDK

One package, @authsec/sdk, covers both sides of the MCP auth protocol:

You are buildingYou needStart here
An MCP server that must be protectedToken validation + per-tool RBACProtect an MCP server
An agent/service that calls a protected serverA token — as itself (M2M) or on behalf of a userCall a protected server · Act on behalf of a user

The runtime is a parity port of the Python and Go SDKs — same request lifecycle, same denial taxonomy, same configuration fields (in camelCase). If you've read the Go SDK guide, the concepts transfer directly; the differences are called out below.

Install​

npm install @authsec/sdk express

Requires Node 18+ (the runtime uses the built-in fetch and WebCrypto). For the server helpers, express is an optional peer dependency — mountMCP accepts any Express-shaped app, so Fastify/Koa/custom hosts use the Runtime class directly (see Manual Runtime).

Two APIs in one package​

The package ships two independent surfaces; use whichever fits:

// Runtime API (Go/Python parity) — recommended for new MCP servers
import { mountMCP, Runtime, loadConfigFromEnv } from "@authsec/sdk";

// Legacy decorator API — still supported (see the end of this page)
import { protectedByAuthSec, runMcpServerWithOAuth } from "@authsec/sdk";

The rest of this guide uses the runtime API. The legacy decorator API is documented at the end for existing projects.


Protect an MCP server​

The problem​

An MCP server exposes tools an AI client can call. Without an auth layer, any client that reaches the endpoint can call any tool — MCP has no notion of who is calling or what they may do. mountMCP adds that layer: it wraps your /mcp route so every request is authenticated and every tools/call is authorized against a per-tool scope policy you control from the AuthSec dashboard.

Quickstart​

import express from "express";
import { loadConfigFromEnv, mountMCP } from "@authsec/sdk";

const app = express();
app.use(express.json());

await mountMCP(app, {
config: loadConfigFromEnv(),
path: "/mcp",
tools: [
{ name: "read_note", description: "Read a note", suggested_scopes: ["notes:read"] },
{ name: "write_note", description: "Write a note", suggested_scopes: ["notes:write"] },
],
});

// This handler only runs for requests that already passed auth + authorization.
app.post("/mcp", (req, res) => {
const principal = (req as any).locals?.principal;
res.json({
jsonrpc: "2.0",
id: (req.body as any)?.id,
result: { actor: principal?.subject }, // the token's `sub` claim
});
});

app.listen(8080);

What each part does, and what mountMCP handles for you:

  • loadConfigFromEnv() reads the AUTHSEC_* variables into a Config (full list under Configuration). It only parses — the actual validation happens inside Runtime.create, which throws a specific error for a missing field.
  • mountMCP(app, { config, path, tools }) is the whole integration. It is async (it performs the initial scope-matrix fetch), and it:
    1. builds and validates the Runtime;
    2. registers a GET route at the RFC 9728 metadata path (/.well-known/oauth-protected-resource/mcp) so clients can discover where to authenticate — you never write this endpoint;
    3. publishes the tool manifest (the tools array) to AuthSec so your tools appear on the dashboard's Tools tab for scope mapping;
    4. installs middleware on path that validates the bearer token, authorizes tools/call, filters tools/list, and attaches the principal. It returns the Runtime so you can reuse it in tests or custom handlers.
  • tools entries accept name (the canonical tool name clients call), description, and suggested_scopes (admin-facing hints; the admin maps the real required scope in the UI). These do not enforce anything at runtime — enforcement comes from the dashboard scope matrix.
  • (req as any).locals?.principal is set by the middleware on every authorized request. It's a Principal with subject (the token sub), scopes, audience, issuer, claims (the raw claim map), and active. Read principal.subject — not principal.sub.

That's the whole integration. Bearer validation, scope enforcement, RFC 9728 metadata, manifest publishing, tools/list filtering, and structured denials all happen inside mountMCP.

How the middleware works, request by request​

mountMCP's handler (runtime/server.ts) runs this on every request to path:

POST /mcp   { jsonrpc:"2.0", method:"tools/call", params:{ name:"write_note" } }
Authorization: Bearer eyJ...
│
▼
1. Extract the bearer token from the Authorization header.
2. runtime.authorize(token, "") — validate the token:
• JWT signature (RS256 via JWKS) + iss + exp, and/or RFC 7662 introspection
(per validationMode); introspection catches revoked tokens.
• audience: the token's aud must include cfg.resourceUri.
Not valid → build a denial (step 5).
3. For each tools/call in the body: runtime.authorizePrincipal(principal, name)
• tool not in the scope matrix → deny (fail-closed)
• tool public (empty scopes) → allow
• tool scoped → allow iff the token has ANY required scope
4. tools/list responses are filtered — the caller only sees tools its scopes allow.
5. Allowed → attach req.locals.principal and call next(). Denied → see below.

How denials are delivered​

Like the Go and Python runtimes, the TypeScript middleware is MCP-client-aware — it doesn't always return a raw HTTP status:

  • No token, or a non-JSON-RPC body → a standard HTTP challenge: 401 (invalid_token) or 403 (insufficient_scope) with a WWW-Authenticate header and a JSON body (error, error_description, and, for scope failures, required_scopes/granted_scopes/tool).
  • A token is present and the body is JSON-RPC → the denial is returned in-band: HTTP 200 with a JSON-RPC error (or a tools/call result with isError: true) carrying _meta.authsec / data.authsec and a plain-English message, so an MCP client renders an actionable error instead of dropping the session. JSON-RPC error codes: -32003 for 403, -32001 for 401.
  • MCP handshake methods (initialize, notifications/initialized, ping) pass through even with an invalid token, so a session survives a mid-session token expiry.
  • Policy backend unreachable under remote_required → 503 (policy_unavailable), never a silent allow.

Note the wire code for a scope failure is insufficient_scope (RFC 6750), even though the internal DenialCode enum spells it scope_insufficient — the middleware maps it on the way out.

Reading the principal in a handler​

app.post("/mcp", (req, res) => {
const principal = (req as any).locals?.principal;
// principal.subject, principal.scopes, principal.claims.workspace_id, …
if ((req.body as any)?.method === "tools/call" &&
(req.body as any)?.params?.name === "read_note") {
return readNoteFor(principal.subject, (req.body as any).params.arguments, res);
}
});

req.locals.principal is populated on every authorized request; it is absent on denied requests (they never reach your handler).

Manual Runtime (Fastify, Koa, custom hosts)​

mountMCP targets Express. On any other server, drive the Runtime yourself — it's the same engine, exposed as one authorize call:

import { Runtime, loadConfigFromEnv } from "@authsec/sdk";

const runtime = await Runtime.create(loadConfigFromEnv());

// In your request handler, per request:
const result = await runtime.authorize(bearerToken, toolId); // toolId "" to only validate
if (!result.allowed) {
return reply
.code(result.denial.status) // 401 | 403 | 503
.header("WWW-Authenticate", result.denial.wwwAuthenticate)
.send({ error: result.denial.code, error_description: result.denial.description });
}
// result.principal is the validated Principal
  • Runtime.create(cfg) is async — it validates the config and performs the initial scope-matrix fetch (mandatory under remote_required, so a misconfiguration fails at boot, not on the first request).
  • runtime.authorize(token, toolId) returns a discriminated union: { allowed: true, principal } or { allowed: false, denial }. The denial carries status, code, description, wwwAuthenticate, and (for scope failures) requiredScopes/grantedScopes/tool.
  • Pass toolId: "" to validate the token without a per-tool check (e.g. once per request), then call runtime.authorizePrincipal(principal, name) for each tools/call — exactly what the Express middleware does internally.
  • For the metadata endpoint, call runtime.getAuthoritativeScopes() and serve it at buildResourceMetadataPath(cfg.resourceUri).

Configuration​

loadConfigFromEnv() reads these variables. The Config interface uses camelCase; the env names match the Python and Go SDKs (and the .env block the admin UI emits — copy it, don't hand-write).

Env varConfig fieldWhat it is
AUTHSEC_ISSUERissuerAuthSec's issuer URL; must equal the token iss
AUTHSEC_AUTHORIZATION_SERVERauthorizationServerAPI origin for policy/manifest calls (defaults to issuer)
AUTHSEC_JWKS_URLjwksUrlPublic key set for local JWT validation
AUTHSEC_INTROSPECTION_URLintrospectionUrlRFC 7662 introspection URL
AUTHSEC_INTROSPECTION_CLIENT_IDintrospectionClientIdBasic-auth username (usually the Application UUID)
AUTHSEC_INTROSPECTION_CLIENT_SECRETintrospectionClientSecretBasic-auth password (shown once at registration)
AUTHSEC_RESOURCE_URIresourceUriThe Application's Resource URI; must equal the token aud
AUTHSEC_RESOURCE_NAMEresourceNameHuman name for metadata and logs
AUTHSEC_RESOURCE_SERVER_IDresourceServerIdApplication UUID; when set, the SDK fetches the scope matrix
AUTHSEC_SUPPORTED_SCOPESsupportedScopesScopes advertised in PRM (fallback for the live matrix)
AUTHSEC_POLICY_MODEpolicyModeremote_required / remote_with_local_fallback / local_only / open
AUTHSEC_VALIDATION_MODEvalidationModejwt_and_introspect / jwt_only / introspection_only / jwt_or_introspect
AUTHSEC_PUBLISH_MANIFESTpublishManifest1/true/yes to push the tool inventory on startup
AUTHSEC_TOOL_SCOPES_JSONtoolScopesLocal tool→scope map (JSON); required for remote_with_local_fallback
AUTHSEC_TOOL_SCOPE_SUGGESTIONS_JSONtoolScopeSuggestionsPer-tool manifest hints (JSON; admin-facing only)

Fields with no env var — set them on the Config object after loadConfigFromEnv(): toolInventoryProvider (escape hatch for manifest publishing), scopeMatrixCacheTtlSeconds (default 300 = 5 minutes — how long fetched policy is cached), bearerMethodsSupported, and requestTimeoutSeconds (default 10).

Modes. policyMode decides where per-tool policy comes from and what happens when AuthSec is unreachable — remote_required (fetch from AuthSec, deny 503 if unreachable — the production default), remote_with_local_fallback (fall back to toolScopes), local_only (use toolScopes only), open (any valid token may call any tool — an observe mode). validationMode decides how a token is checked; jwt_and_introspect is the strict default when both jwksUrl and introspectionUrl are set. When either mode is unset, the SDK infers it from what you configured (an resourceServerId implies remote_required; both URLs imply jwt_and_introspect).

Compatibility aliases. loadConfigFromEnv also accepts AUTHSEC_JWKS_URI, AUTHSEC_INTROSPECTION_ENDPOINT, AUTHSEC_INTROSPECTION_ID, AUTHSEC_INTROSPECTION_SECRET, and AUTHSEC_RESOURCE; AUTHSEC_POLICY_MODE also accepts enforce (→ remote_required) and observe (→ open). Prefer the canonical names above. Pass a different prefix with loadConfigFromEnv("MYAPP_").


Call a protected server (M2M)​

When a service, cron job, or backend calls a protected MCP server as itself, use AgentIdentity to acquire a client_credentials token. You give it the MCP server's URL; it discovers the token endpoint from that server's metadata (PRM → AS metadata), authenticates, and caches the token.

import { AgentIdentity } from "@authsec/sdk";

const identity = new AgentIdentity({
issuer: process.env.AUTHSEC_ISSUER!,
clientId: process.env.SA_CLIENT_ID!,
clientSecret: process.env.SA_CLIENT_SECRET!, // client_secret_basic
});

const token = await identity.accessFor("https://payments.example.com/mcp", {
requestedScopes: ["payments:read"],
});
// → send as `Authorization: Bearer ${token}` on every MCP call.
  • new AgentIdentity({ issuer, clientId, clientSecret }) configures the shared-secret method (client_secret_basic — the secret is sent as HTTP Basic on each token request). The constructor throws if issuer or clientId is missing.
  • accessFor(resource, { requestedScopes }) runs the flow and returns the token string. It caches per resource (served until ~30 s before expiry), so construct one AgentIdentity per process and reuse it. On a 401 from the MCP server, call identity.clearCache(resource) and retry once.
  • The service account must be granted a role on the target application first, or the token request fails with access_denied — the credential authenticates but reaches nothing until an admin grants access.

Private-key JWT (no secret on the wire)​

To avoid sending a secret, use private_key_jwt: the SDK signs a short-lived assertion (RS256, 5-minute lifetime, audience-bound to the token endpoint) with a WebCrypto private key. Import your PKCS#8 key once and pass it as { key, kid }:

import { AgentIdentity } from "@authsec/sdk";

// Import a PKCS#8 RSA private key into a WebCrypto CryptoKey for RS256 signing.
const key = await crypto.subtle.importKey(
"pkcs8",
pkcs8DerBytes, // your key as DER (decode the PEM body)
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
false,
["sign"],
);

const identity = new AgentIdentity({
issuer: process.env.AUTHSEC_ISSUER!,
clientId: process.env.SA_CLIENT_ID!,
privateKey: { key, kid: "key-1" }, // kid must match your published JWKS
});

const token = await identity.accessFor("https://payments.example.com/mcp", {
requestedScopes: ["payments:read"],
});
  • privateKey: { key, kid } is mutually exclusive with clientSecret. key is a WebCrypto CryptoKey (RSASSA-PKCS1-v1_5); kid must match the key id in the JWKS you publish for the service account (see the Go private-key-JWT guide for JWKS hosting — the server side is identical across SDKs).
  • Each accessFor signs a fresh assertion internally; the private key never leaves the process.

For M2M concepts (the three methods, service accounts, granting access), see the Go M2M guide — the dashboard side is language-agnostic.


Act on behalf of a user (ID-JAG)​

When an agent acts for a signed-in user (a copilot running errands for Alice), it should call with Alice's permissions, auditable as "Alice, via agent X." That's delegation via ID-JAG (the cross-app-access / XAA flow): the agent exchanges proof of the user's login for a scoped token carrying both identities (sub = the user, act.client_id = the agent). See the concept in depth.

The TypeScript SDK has no BrowserLogin. Unlike the Go SDK, you supply the user's id_token yourself — from your app's own OIDC login (NextAuth, Auth.js, Passport, a SPA's OIDC client, etc.) — and pass it as userSession.subject_token. Everything after that (token-exchange → ID-JAG → jwt-bearer) is handled by accessFor.

import { AgentIdentity, PendingApprovalError, pollUntilApproved } from "@authsec/sdk";

const identity = new AgentIdentity({
issuer: process.env.AUTHSEC_ISSUER!,
clientId: process.env.AGENT_CLIENT_ID!,
clientSecret: process.env.AGENT_CLIENT_SECRET!,
idpIssuer: process.env.AUTHSEC_ISSUER!, // enables the XAA / ID-JAG path
preferredMode: "auto",
});

const resource = "https://payments.example.com/mcp";
const accessForOptions = {
userSession: { subject_token: userIdToken }, // the user's OIDC id_token
requestedScopes: ["tickets.read"],
};

let token: string;
try {
token = await identity.accessFor(resource, accessForOptions);
} catch (err) {
if (err instanceof PendingApprovalError) {
// First contact needs a one-time admin approval — poll until approved.
token = await pollUntilApproved(identity, resource, err.statusUrl, { accessForOptions });
} else {
throw err;
}
}
// token: sub = the user, act.client_id = this agent (auditable delegation)

How it works, and what the SDK handles:

  • idpIssuer is what enables delegation. Without it — or without a userSession, or on an AS that doesn't advertise the XAA grant types — accessFor silently uses the M2M direct path. Set it to your enterprise IdP issuer.
  • userSession.subject_token is the user's id_token. accessFor performs the two-hop ID-JAG exchange internally: token-exchange (id_token → ID-JAG), then jwt-bearer (ID-JAG → a scoped access token).
  • preferredMode forces flow selection: "auto" (default — decide from AS metadata and a requester-bootstrap call), "direct-only" (skip delegation), or "xaa-allowed" (fail rather than silently fall back to direct).
  • accessFor throws PendingApprovalError (with requestId and statusUrl) on first contact — not a failure, but a signal that the server's admin must approve the connection once (the admin half of the double opt-in; the user consent is the other half, captured when they logged into your app).
  • pollUntilApproved(identity, resource, statusUrl, { accessForOptions }) polls the status URL (defaults: 2 s interval, 150 attempts ≈ 5 min) and, on approval, clears the cache and calls accessFor again with the same accessForOptions — so pass userSession/requestedScopes again here. It throws ApprovalDeniedError if the admin declines and ConnectionRevokedError if access is revoked.

Requested scopes must exist on the target server — request only scopes listed in its PRM scopes_supported, or the request looks like "pending forever." This is the most common mistake, the same as in the Go SDK.

Error taxonomy​

Agent-side failures are Error subclasses of AuthSecIdentityError (each with a code and httpStatus). Match with instanceof:

ClasscodeMeaning
PendingApprovalErroraccess_pendingFirst contact — admin approval pending (requestId, statusUrl)
ApprovalDeniedErrorapproval_deniedAdmin declined
ConnectionRevokedErrorconnection_revokedA previously approved connection was revoked
TrustedIssuerMissingErrortrusted_issuer_missingThe IdP isn't trusted by the AuthSec AS
SubjectMappingFailedErrorsubject_mapping_failedCouldn't map the external identity to a local user
CredentialInvalidErrorcredential_invalidAgent client id/secret wrong (message has detail)
ResourceNotRegisteredErrorresource_not_registeredThe MCP URL isn't a registered application

Verify it works​

Prove a token actually opens the door with an authenticated tools/list:

const res = await fetch("https://payments.example.com/mcp", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
},
body: JSON.stringify({ jsonrpc: "2.0", method: "tools/list", id: 1 }),
});
const data = await res.json();
// data.result.tools contains only the tools your granted scopes allow.
  • A 200 whose tools array is scope-filtered confirms the whole chain (mint → audience match → tools/list filter).
  • On the server side, curl the metadata endpoint to confirm discovery works: curl https://your-server/.well-known/oauth-protected-resource/mcp returns resource, authorization_servers, and scopes_supported.
  • An unauthenticated POST /mcp should return 401 with a WWW-Authenticate header — if it returns 200, the middleware isn't wrapping that route.

Troubleshooting​

SymptomCauseFix
401 invalid_audience / invalid_token "audience mismatch"AUTHSEC_RESOURCE_URI doesn't match the URL clients callCopy the env block from the Application detail page; make scheme/host/path identical
403 insufficient_scope on a mapped toolToken lacks the tool's scope, or the tool isn't mappedMap the tool in the admin UI; re-consent for the missing scope
503 policy_unavailableSDK can't reach AuthSec's policy endpoint under remote_requiredCheck egress + introspection credentials; deny-closed is by design
access_denied when calling accessForService account/agent has no role on the target appGrant a role on the application's Access/Connections tab
Stuck in PendingApprovalError foreverRequested scopes don't exist on the target serverUse scopes from the server's PRM scopes_supported
accessFor never delegates (acts as M2M)idpIssuer unset, no userSession, or AS lacks XAA grantsSet idpIssuer, pass userSession.subject_token
mountMCP throws at startupConfig invalid (validateConfig) or initial policy fetch failedThe error names the field; finish launch or relax policyMode for onboarding
npm install/build complains about crypto or fetchNode < 18Upgrade to Node 18+

Legacy decorator API​

The package keeps an older, decorator-style API for building a standalone MCP server without wiring Express yourself. It coexists with the runtime API.

import { protectedByAuthSec, runMcpServerWithOAuth } from "@authsec/sdk";

const adminTool = protectedByAuthSec(
{ toolName: "admin_dashboard", roles: ["admin"], description: "Admin dashboard" },
async (args, session) => {
return [{ type: "text", text: `Hello ${session.userId}` }];
},
);

runMcpServerWithOAuth({
tools: [adminTool],
clientId: "your-client-id",
appName: "my-app",
});
  • protectedByAuthSec({ toolName, roles?, scopes?, description }, handler) wraps a tool handler so it only runs for callers who satisfy the RBAC requirements; the handler receives the authenticated session.
  • runMcpServerWithOAuth({ tools, clientId, appName }) starts a full MCP server with the OAuth flow wired in.

For new MCP servers, prefer the runtime API (mountMCP / Runtime) — it matches the Go and Python SDKs and gives you the full request lifecycle described above.