TypeScript SDK
One package, @authsec/sdk, covers both sides of the MCP auth protocol:
| You are building | You need | Start here |
|---|---|---|
| An MCP server that must be protected | Token validation + per-tool RBAC | Protect an MCP server |
| An agent/service that calls a protected server | A token — as itself (M2M) or on behalf of a user | Call 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 theAUTHSEC_*variables into aConfig(full list under Configuration). It only parses — the actual validation happens insideRuntime.create, which throws a specific error for a missing field.mountMCP(app, { config, path, tools })is the whole integration. It isasync(it performs the initial scope-matrix fetch), and it:- builds and validates the
Runtime; - 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; - publishes the tool manifest (the
toolsarray) to AuthSec so your tools appear on the dashboard's Tools tab for scope mapping; - installs middleware on
paththat validates the bearer token, authorizestools/call, filterstools/list, and attaches the principal. It returns theRuntimeso you can reuse it in tests or custom handlers.
- builds and validates the
toolsentries acceptname(the canonical tool name clients call),description, andsuggested_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?.principalis set by the middleware on every authorized request. It's aPrincipalwithsubject(the tokensub),scopes,audience,issuer,claims(the raw claim map), andactive. Readprincipal.subject— notprincipal.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) or403(insufficient_scope) with aWWW-Authenticateheader 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
200with a JSON-RPC error (or atools/callresult withisError: true) carrying_meta.authsec/data.authsecand a plain-English message, so an MCP client renders an actionable error instead of dropping the session. JSON-RPC error codes:-32003for403,-32001for401. - 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)isasync— it validates the config and performs the initial scope-matrix fetch (mandatory underremote_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 }. Thedenialcarriesstatus,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 callruntime.authorizePrincipal(principal, name)for eachtools/call— exactly what the Express middleware does internally. - For the metadata endpoint, call
runtime.getAuthoritativeScopes()and serve it atbuildResourceMetadataPath(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 var | Config field | What it is |
|---|---|---|
AUTHSEC_ISSUER | issuer | AuthSec's issuer URL; must equal the token iss |
AUTHSEC_AUTHORIZATION_SERVER | authorizationServer | API origin for policy/manifest calls (defaults to issuer) |
AUTHSEC_JWKS_URL | jwksUrl | Public key set for local JWT validation |
AUTHSEC_INTROSPECTION_URL | introspectionUrl | RFC 7662 introspection URL |
AUTHSEC_INTROSPECTION_CLIENT_ID | introspectionClientId | Basic-auth username (usually the Application UUID) |
AUTHSEC_INTROSPECTION_CLIENT_SECRET | introspectionClientSecret | Basic-auth password (shown once at registration) |
AUTHSEC_RESOURCE_URI | resourceUri | The Application's Resource URI; must equal the token aud |
AUTHSEC_RESOURCE_NAME | resourceName | Human name for metadata and logs |
AUTHSEC_RESOURCE_SERVER_ID | resourceServerId | Application UUID; when set, the SDK fetches the scope matrix |
AUTHSEC_SUPPORTED_SCOPES | supportedScopes | Scopes advertised in PRM (fallback for the live matrix) |
AUTHSEC_POLICY_MODE | policyMode | remote_required / remote_with_local_fallback / local_only / open |
AUTHSEC_VALIDATION_MODE | validationMode | jwt_and_introspect / jwt_only / introspection_only / jwt_or_introspect |
AUTHSEC_PUBLISH_MANIFEST | publishManifest | 1/true/yes to push the tool inventory on startup |
AUTHSEC_TOOL_SCOPES_JSON | toolScopes | Local tool→scope map (JSON); required for remote_with_local_fallback |
AUTHSEC_TOOL_SCOPE_SUGGESTIONS_JSON | toolScopeSuggestions | Per-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 ifissuerorclientIdis missing.accessFor(resource, { requestedScopes })runs the flow and returns the token string. It caches per resource (served until ~30 s before expiry), so construct oneAgentIdentityper process and reuse it. On a401from the MCP server, callidentity.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 withclientSecret.keyis a WebCryptoCryptoKey(RSASSA-PKCS1-v1_5);kidmust 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
accessForsigns 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'sid_tokenyourself — from your app's own OIDC login (NextAuth, Auth.js, Passport, a SPA's OIDC client, etc.) — and pass it asuserSession.subject_token. Everything after that (token-exchange → ID-JAG → jwt-bearer) is handled byaccessFor.
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:
idpIssueris what enables delegation. Without it — or without auserSession, or on an AS that doesn't advertise the XAA grant types —accessForsilently uses the M2M direct path. Set it to your enterprise IdP issuer.userSession.subject_tokenis the user'sid_token.accessForperforms the two-hop ID-JAG exchange internally: token-exchange (id_token→ ID-JAG), then jwt-bearer (ID-JAG → a scoped access token).preferredModeforces 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).accessForthrowsPendingApprovalError(withrequestIdandstatusUrl) 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 callsaccessForagain with the sameaccessForOptions— so passuserSession/requestedScopesagain here. It throwsApprovalDeniedErrorif the admin declines andConnectionRevokedErrorif 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:
| Class | code | Meaning |
|---|---|---|
PendingApprovalError | access_pending | First contact — admin approval pending (requestId, statusUrl) |
ApprovalDeniedError | approval_denied | Admin declined |
ConnectionRevokedError | connection_revoked | A previously approved connection was revoked |
TrustedIssuerMissingError | trusted_issuer_missing | The IdP isn't trusted by the AuthSec AS |
SubjectMappingFailedError | subject_mapping_failed | Couldn't map the external identity to a local user |
CredentialInvalidError | credential_invalid | Agent client id/secret wrong (message has detail) |
ResourceNotRegisteredError | resource_not_registered | The 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
200whosetoolsarray is scope-filtered confirms the whole chain (mint → audience match →tools/listfilter). - On the server side,
curlthe metadata endpoint to confirm discovery works:curl https://your-server/.well-known/oauth-protected-resource/mcpreturnsresource,authorization_servers, andscopes_supported. - An unauthenticated
POST /mcpshould return401with aWWW-Authenticateheader — if it returns200, the middleware isn't wrapping that route.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
401 invalid_audience / invalid_token "audience mismatch" | AUTHSEC_RESOURCE_URI doesn't match the URL clients call | Copy the env block from the Application detail page; make scheme/host/path identical |
403 insufficient_scope on a mapped tool | Token lacks the tool's scope, or the tool isn't mapped | Map the tool in the admin UI; re-consent for the missing scope |
503 policy_unavailable | SDK can't reach AuthSec's policy endpoint under remote_required | Check egress + introspection credentials; deny-closed is by design |
access_denied when calling accessFor | Service account/agent has no role on the target app | Grant a role on the application's Access/Connections tab |
Stuck in PendingApprovalError forever | Requested scopes don't exist on the target server | Use scopes from the server's PRM scopes_supported |
accessFor never delegates (acts as M2M) | idpIssuer unset, no userSession, or AS lacks XAA grants | Set idpIssuer, pass userSession.subject_token |
mountMCP throws at startup | Config invalid (validateConfig) or initial policy fetch failed | The error names the field; finish launch or relax policyMode for onboarding |
npm install/build complains about crypto or fetch | Node < 18 | Upgrade 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 authenticatedsession.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.
Related
- How AuthSec protects your MCP server — the request model
- Go SDK — the same runtime, with
BrowserLoginand SPIFFE credentials - Python SDK — the same API in Python
- Register an application — register before installing
- SDK FAQ