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.subjectnot 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_required503 (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.