OAuth

August 26, 2026 · View on GitHub

ViteMCP ships an OAuth 2.1 proxy that lets MCP clients authenticate against providers which do not support Dynamic Client Registration. It presents a DCR-compliant face to the client while using pre-registered credentials upstream, and handles the whole authorization flow: PKCE, consent, token exchange, refresh, and storage.

This is the complete OAuth reference. For the abbreviated version, see Authentication in the README.

Contents

How it works

1. Client → Proxy      DCR registration; proxy returns its own credentials
2. Client → Proxy      Authorization request with the client's PKCE challenge
3. Proxy  → User       Consent screen (prevents confused-deputy)
4. Proxy  → Upstream   Authorization with the proxy's own PKCE challenge
5. Upstream → Proxy    Authorization code, exchanged for upstream tokens
6. Proxy  → Client     Proxy-issued authorization code
7. Client → Proxy      Token exchange with the client's PKCE verifier

Two PKCE pairs are in play: one between client and proxy, one between proxy and upstream. Neither party ever sees the other's verifier.

Only S256 is accepted. plain makes the challenge and the verifier the same value, so the secret that redeems an authorization code ends up in browser history, referrer headers and proxy logs — RFC 7636 §4.2 requires S256 of any client that can hash, and OAuth 2.1 removes plain outright. Set allowPlainPkce: true for the rare client that genuinely cannot compute a SHA-256 digest; it re-advertises plain in the metadata as well.

These endpoints are registered automatically:

EndpointMethodPurpose
/oauth/registerPOSTRFC 7591 Dynamic Client Registration
/oauth/authorizeGETAuthorization initiation
/oauth/callbackGETProvider callback handler
/oauth/consentGET/POSTUser consent screen
/oauth/tokenPOSTToken exchange and refresh
/.well-known/oauth-authorization-serverGETRFC 8414 discovery metadata

Quick start

Pre-configured provider

The auth option is the shortest path. Everything else on this page is optional.

import {
  ViteMCP,
  getAuthSession,
  GoogleProvider,
  requireAuth,
} from "@vitemcp/server";

const server = new ViteMCP({
  auth: new GoogleProvider({
    baseUrl: "https://your-server.com",
    clientId: process.env.GOOGLE_CLIENT_ID!,
    clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    scopes: ["openid", "profile", "email"],
  }),
  name: "My Server",
  version: "1.0.0",
});

server.addTool({
  canAccess: requireAuth,
  description: "Get user profile from Google",
  execute: async (_args, { auth }) => {
    const { accessToken } = getAuthSession(auth);
    const response = await fetch(
      "https://www.googleapis.com/oauth2/v2/userinfo",
      { headers: { Authorization: `Bearer ${accessToken}` } },
    );
    return JSON.stringify(await response.json());
  },
  name: "get-profile",
});

await server.start({
  transportType: "httpStream",
  httpStream: { port: 3000 },
});

Requires the httpStream transport.

Any other provider

For providers without a pre-built class (Auth0, Okta, SAP, …), use OAuthProvider and supply the two endpoints yourself:

import { ViteMCP, OAuthProvider } from "@vitemcp/server";

const server = new ViteMCP({
  auth: new OAuthProvider({
    authorizationEndpoint: "https://provider.com/oauth/authorize",
    baseUrl: "https://your-server.com",
    clientId: process.env.OAUTH_CLIENT_ID!,
    clientSecret: process.env.OAUTH_CLIENT_SECRET!,
    scopes: ["openid", "profile"],
    tokenEndpoint: "https://provider.com/oauth/token",
  }),
  name: "My Server",
  version: "1.0.0",
});

Driving the proxy directly

When you need control over proxy behaviour, construct an OAuthProxy and pass it through the oauth option:

import { ViteMCP } from "@vitemcp/server";
import { OAuthProxy } from "@vitemcp/server/auth";

const authProxy = new OAuthProxy({
  baseUrl: "https://your-server.com",
  scopes: ["openid", "profile"],
  upstreamAuthorizationEndpoint: "https://provider.com/oauth/authorize",
  upstreamClientId: process.env.OAUTH_CLIENT_ID!,
  upstreamClientSecret: process.env.OAUTH_CLIENT_SECRET!,
  upstreamTokenEndpoint: "https://provider.com/oauth/token",
});

const server = new ViteMCP({
  name: "My Server",
  oauth: {
    authorizationServer: authProxy.getAuthorizationServerMetadata(),
    enabled: true,
    proxy: authProxy,
  },
  version: "1.0.0",
});

A provider class exposes its own proxy via getProxy(), so you can take the same route starting from GoogleProvider and friends.

Provider setup

Every provider needs the redirect URI registered as {baseUrl}/oauth/callback.

Google

Create an OAuth 2.0 Client ID in the Google Cloud Console with application type "Web application".

import { ViteMCP, GoogleProvider } from "@vitemcp/server";

const server = new ViteMCP({
  auth: new GoogleProvider({
    baseUrl: "https://your-server.com",
    clientId: "xxx.apps.googleusercontent.com",
    clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    scopes: ["openid", "profile", "email"],
  }),
  name: "My Server",
  version: "1.0.0",
});

Common scopes: openid, profile, email, https://www.googleapis.com/auth/gmail.readonly.

Google only issues a refresh_token when the authorization request carries access_type=offline — see Extra authorization parameters.

GitHub

Create an OAuth App under Developer Settings.

import { ViteMCP, GitHubProvider } from "@vitemcp/server";

const server = new ViteMCP({
  auth: new GitHubProvider({
    baseUrl: "https://your-server.com",
    clientId: process.env.GITHUB_CLIENT_ID!,
    clientSecret: process.env.GITHUB_CLIENT_SECRET!,
    scopes: ["read:user", "user:email"],
  }),
  name: "My Server",
  version: "1.0.0",
});

Common scopes: read:user, user:email, repo, read:org.

Azure / Entra ID

Register an application in the Azure Portal and create a client secret under "Certificates & secrets".

import { ViteMCP, AzureProvider } from "@vitemcp/server";

const server = new ViteMCP({
  auth: new AzureProvider({
    baseUrl: "https://your-server.com",
    clientId: process.env.AZURE_CLIENT_ID!,
    clientSecret: process.env.AZURE_CLIENT_SECRET!,
    scopes: ["openid", "profile", "email"],
    tenantId: "common",
  }),
  name: "My Server",
  version: "1.0.0",
});

tenantId accepts common (any Azure AD account), organizations (any organizational account), consumers (personal Microsoft accounts), or a specific tenant ID. Common scopes: openid, profile, email, User.Read, Mail.Read.

Tenant-scoped providers often return an iss that differs from the authorization endpoint's origin. If so, set upstreamIssuer to the real issuer or the RFC 9207 check will reject the callback.

Protecting tools

Configuring auth already closes the endpoint: a request that arrives without a valid bearer token is refused at the transport with 401 and an RFC 6750 WWW-Authenticate challenge, which is what tells the client where to log in. Nothing behind the gate is reachable anonymously — canAccess decides what an authenticated caller may use, not whether authentication happened.

If you want part of the server to stay public, set allowAnonymous: true and guard everything else with canAccess. Unauthenticated requests are then served with auth set to undefined.

canAccess filters rejected tools out of tools/list entirely, so clients never see the ones they may not call.

import {
  getAuthSession,
  requireAll,
  requireAny,
  requireAuth,
  requireRole,
  requireScopes,
} from "@vitemcp/server";

// Any authenticated user
server.addTool({ canAccess: requireAuth, name: "protected-tool" /* … */ });

// Specific OAuth scopes
server.addTool({
  canAccess: requireScopes("read:user", "write:data"),
  name: "scoped-tool",
  // …
});

// Specific role
server.addTool({ canAccess: requireRole("admin"), name: "admin-tool" /* … */ });

// AND
server.addTool({
  canAccess: requireAll(requireAuth, requireScopes("admin")),
  name: "full-access-tool",
  // …
});

// OR
server.addTool({
  canAccess: requireAny(requireRole("admin"), requireRole("moderator")),
  name: "staff-tool",
  // …
});

For anything these don't cover, pass a function. It receives whatever your authenticate hook returned for the request — an AuthProvider puts the session there:

server.addTool({
  canAccess: (auth) => {
    if (!auth) return false;
    return auth.role === "admin" || auth.permissions?.includes("special");
  },
  description: "Custom authorization logic",
  execute: async () => "Custom access granted!",
  name: "custom-auth-tool",
});

Inside execute, getAuthSession gives type-safe access to the session and throws a clear error when the request was never authenticated:

import { getAuthSession, GoogleSession } from "@vitemcp/server";

server.addTool({
  canAccess: requireAuth,
  execute: async (_args, { auth }) => {
    const { accessToken } = getAuthSession(auth);
    // Or, with provider-specific typing:
    // const { accessToken } = getAuthSession<GoogleSession>(auth);

    const response = await fetch("https://api.example.com/user", {
      headers: { Authorization: `Bearer ${accessToken}` },
    });
    return JSON.stringify(await response.json());
  },
  name: "get-profile",
});

You can read auth.accessToken directly instead, but then undefined is yours to handle.

Configuration

interface OAuthProxyConfig {
  // Required
  baseUrl: string;
  upstreamAuthorizationEndpoint: string;
  upstreamClientId: string;
  upstreamClientSecret: string;
  upstreamTokenEndpoint: string;

  // Flow behaviour
  allowedRedirectUriPatterns?: string[];
  allowPlainPkce?: boolean; // default: false
  authorizationCodeTtl?: number; // seconds, default: 300
  consentRequired?: boolean; // default: true
  consentSigningKey?: string; // auto-generated if absent
  extraAuthorizationParams?: Record<string, string>;
  forwardPkce?: boolean; // default: false
  redirectPath?: string; // default: "/oauth/callback"
  scopes?: string[]; // provider-specific defaults
  transactionTtl?: number; // seconds, default: 600
  upstreamIssuer?: string; // when issuer ≠ endpoint origin

  // Token swap (enabled by default)
  accessTokenTtl?: number; // seconds, default: 3600
  enableTokenSwap?: boolean; // default: true
  jwtSigningKey?: string; // auto-generated if absent
  refreshTokenTtl?: number; // seconds, default: 2592000

  // Storage
  encryptionKey?: false | string; // auto-generated if absent
  tokenStorage?: TokenStorage; // default: MemoryTokenStorage
  tokenVerifier?: TokenVerifier; // custom JWT verification
}

Every key that is "auto-generated if absent" regenerates on restart and differs per process. That is fine for development and wrong for anything else — see Running multiple instances.

Extra authorization parameters

Some providers require non-standard parameters on the authorization request. Google, for example, only issues a refresh_token when the request includes access_type=offline; without it access expires after an hour and can never be renewed:

const authProxy = new OAuthProxy({
  // …
  extraAuthorizationParams: {
    access_type: "offline", // Google: issue a refresh_token
    prompt: "consent", // Google: re-issue it on re-auth
  },
});

Core parameters managed by the proxy (client_id, redirect_uri, response_type, state, scope, code_challenge, code_challenge_method) cannot be overridden — entries with those keys are ignored.

Redirect URI patterns

Controls which callback URIs clients may register:

const authProxy = new OAuthProxy({
  // …
  allowedRedirectUriPatterns: [
    "https://*.example.com/*", // wildcard subdomain
    "http://localhost:*", // any localhost port
    "https://app.example.com/callback", // exact match
  ],
});

A pattern is matched one URI component at a time — scheme, host, port, then everything from the path onwards — so a * never reaches past the component it appears in. https://*.example.com/* admits https://app.example.com/anything but not https://evil.com/a.example.com/cb, where the wildcard would otherwise have covered evil.com/a.

Consequences worth knowing:

  • Omit the path and the pattern constrains the authority only. http://localhost:* allows any port and any path; that is what lets an ephemeral loopback client pick its own callback. Write a path to pin one.
  • Hosts and schemes compare case-insensitively, and a default port is normalized: https://app.example.com/* matches https://app.example.com:443/cb.
  • A redirect URI carrying userinfo is always rejected, whatever the patterns say. http://localhost:@evil.com/cb navigates to evil.com, and no legitimate client sends credentials in a callback URL.
  • Private-use schemes (com.example.app:/*) and bracketed IPv6 literals (http://[::1]:*) are supported.

TTLs

const authProxy = new OAuthProxy({
  // …
  accessTokenTtl: 900, // 15 minutes — shorter is safer
  authorizationCodeTtl: 300, // 5 minutes to redeem a code
  refreshTokenTtl: 604800, // 7 days
  transactionTtl: 600, // 10 minutes to finish the flow
});

All values are in seconds.

The consent screen is what stops a confused-deputy attack, so it is on by default. It renders the requesting client and the scopes being granted, sets a signed (HMAC-SHA256) cookie with a 5-minute TTL, and escapes everything it prints.

const authProxy = new GoogleProvider({
  baseUrl: "http://localhost:3000",
  clientId: process.env.GOOGLE_CLIENT_ID!,
  clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
  consentRequired: false, // ⚠️ local development only
});

Shutting down

Both the proxy and its default in-memory storage run a 60-second cleanup sweep, and an uncleared setInterval keeps the Node event loop alive — a process that has stopped serving will not exit on its own. Call destroy() when you are done with it:

const provider = new GoogleProvider({
  /* … */
});
const server = new ViteMCP({ auth: provider /* … */ });

// Order matters: stop() drains in-flight requests, and destroy() is terminal.
await server.stop();
provider.destroy();

Naming the provider like this is the clearest form. When the server is built somewhere you cannot reach — a factory returning a bare ViteMCP, say — read it back off the server instead:

await server.stop();
server.authProvider?.destroy();

It is undefined when OAuth was configured through the oauth option rather than auth; there, hold your own OAuthProxy reference and destroy that.

destroy() does not leave a provider you can restart. Any later use throws, deliberately: rebuilding the proxy would start fresh timers, and start() mounted the OAuth routes against the original proxy reference, so the token endpoint would answer from one instance while authenticate() verified against another — with independently generated signing keys, no token issued by one verifies against the other. To restart, build a new provider.

Driving the proxy directly, call authProxy.destroy() instead. A storage backend you constructed yourself is left running — its lifetime is yours, so destroy it separately. ViteMCP.stop() deliberately does none of this, so a stop()start() cycle keeps working: tearing the proxy down would clear the client registrations the next start() needs.

Token handling

Token swap (default)

By default the proxy does not hand upstream tokens to clients. It stores them server-side and issues its own short-lived JWT instead:

  1. Client exchanges its authorization code.
  2. Proxy exchanges upstream and receives the provider's tokens.
  3. Proxy stores those tokens, encrypted.
  4. Proxy returns a JWT (1 hour by default) to the client.
  5. Proxy maps the JWT's jti back to the stored tokens on later requests.

The provider's credentials never leave your server, client tokens expire quickly, refresh rotation is supported, and validation needs no storage lookup.

import { DiskStore, JWTIssuer, OAuthProxy } from "@vitemcp/server/auth";

const authProxy = new OAuthProxy({
  // …
  jwtSigningKey: await JWTIssuer.deriveKey(process.env.JWT_SECRET!, 100000),
  tokenStorage: new DiskStore({ directory: "/var/lib/vitemcp/oauth" }),
});

To use the upstream tokens from a tool, exchange the client's JWT for them:

server.addTool({
  description: "Call upstream API with the user's token",
  execute: async (args, { auth }) => {
    const upstreamTokens = await authProxy.loadUpstreamTokens(auth.token);
    if (!upstreamTokens) throw new Error("No valid token");

    const response = await fetch("https://api.provider.com/user", {
      headers: { Authorization: `Bearer ${upstreamTokens.accessToken}` },
    });
    return JSON.stringify(await response.json());
  },
  name: "call-api",
});

Mappings are cleaned up on a TTL: access-token mappings expire with the upstream token, refresh-token mappings after 30 days by default.

Passthrough mode

Setting enableTokenSwap: false returns the upstream provider's tokens to the client verbatim and leaves lifecycle management to it. Simpler, and occasionally useful when debugging, but the client then holds provider credentials:

const authProxy = new OAuthProxy({
  // …
  enableTokenSwap: false,
});

const response = await authProxy.exchangeAuthorizationCode(request);
// response.access_token is the upstream provider's access token

JWT issuer

JWTIssuer backs token swap and can be used on its own. It signs with HMAC-SHA256 (HS256):

import { JWTIssuer } from "@vitemcp/server/auth";

const issuer = new JWTIssuer({
  accessTokenTtl: 3600,
  audience: "https://your-server.com",
  issuer: "https://your-server.com",
  refreshTokenTtl: 2592000,
  signingKey: await JWTIssuer.deriveKey(process.env.JWT_SECRET!, 100000),
});

const accessToken = issuer.issueAccessToken("client-123", ["read", "write"]);
const result = await issuer.verify(accessToken);
if (result.valid) console.log(result.claims);

Issued tokens carry iss, aud, client_id, scope, exp, iat, and jti. JWTIssuer.deriveKey(secret, iterations) runs PBKDF2 — always derive production keys from a secret rather than passing a raw string.

Custom claims passthrough

Enabled by default. Claims from the upstream access token and ID token are copied into the proxy-issued JWT, which is what makes RBAC possible downstream:

const authProxy = new OAuthProxy({
  // …
  customClaimsPassthrough: {
    allowComplexClaims: false, // primitives only (default)
    allowedClaims: ["role", "roles", "permissions", "email", "groups"],
    blockedClaims: ["internal_id"],
    claimPrefix: false, // e.g. "upstream_" to namespace them
    fromAccessToken: true,
    fromIdToken: true,
    maxClaimValueSize: 2000, // characters
  },
});

customClaimsPassthrough: true uses the defaults; false disables it.

Access-token claims take precedence; non-overlapping ID-token claims are merged in. Standard JWT claims (aud, iss, exp, iat, nbf, jti, client_id) are never copied. Only JWT-format tokens are inspected — opaque tokens are skipped silently.

Using them for authorization:

server.addTool({
  canAccess: (auth) => {
    const claims = auth?.claims ?? {};
    return claims.role === "admin" || claims.roles?.includes("admin");
  },
  description: "Access admin dashboard",
  execute: async () => "Admin dashboard data…",
  name: "admin-dashboard",
});

Storage

All proxy state — client registrations, in-flight transactions, authorization codes, token mappings — lives in the configured TokenStorage.

BackendPersistenceScopeUse for
MemoryTokenStoragenoneone processdevelopment (default)
DiskStoresurvives restartone hostsingle-host production
Customyourswherever you wantmore than one host (Redis, SQL, DynamoDB)
import { DiskStore } from "@vitemcp/server/auth";

const authProxy = new OAuthProxy({
  // …
  tokenStorage: new DiskStore({
    cleanupIntervalMs: 60000, // default: 60s; lower it under load
    directory: "/var/lib/vitemcp/oauth",
    fileExtension: ".json",
  }),
});

DiskStore cleans up expired entries on its own interval, sanitizes keys against directory traversal, and claims entries with an atomic rename() — so several processes sharing one directory are safe. It is host-local, not a network-distributed store.

It creates the directory 0700 and each entry 0600, keeping codes and tokens off-limits to other users on the host. Those modes are applied at creation, so a directory carried over from an older release keeps whatever it has — run chmod -R go-rwx on it once when upgrading.

Encryption

Storage is encrypted by default. The proxy wraps whatever tokenStorage you give it in EncryptedTokenStorage (AES-256-GCM, scrypt-derived key, authentication tag verified on read) unless it already is one. You do not need to wrap it yourself — and if you do anyway, keep a reference to the backend you wrapped: EncryptedTokenStorage does not forward destroy(), because a wrapper handed a backend cannot know whether it is the last user of it.

const authProxy = new OAuthProxy({
  // …
  encryptionKey: await JWTIssuer.deriveKey(
    process.env.ENCRYPTION_SECRET! + ":storage",
    100000,
  ),
  tokenStorage: new DiskStore({ directory: "/var/lib/vitemcp/oauth" }),
});

Supply encryptionKey in production. Left absent it is generated per process, so a restart makes existing stored state unreadable. encryptionKey: false disables encryption — development and testing only.

Custom backends

Implement TokenStorage:

import { TokenStorage } from "@vitemcp/server/auth";

class RedisTokenStorage implements TokenStorage {
  constructor(private redis: RedisClient) {}

  async save(key: string, value: unknown, ttl?: number): Promise<void> {
    const serialized = JSON.stringify(value);
    if (ttl) await this.redis.setex(key, ttl, serialized);
    else await this.redis.set(key, serialized);
  }

  async get(key: string): Promise<null | unknown> {
    const value = await this.redis.get(key);
    return value ? JSON.parse(value) : null;
  }

  async delete(key: string): Promise<void> {
    await this.redis.del(key);
  }

  async cleanup(): Promise<void> {
    // Redis expires keys itself.
  }

  async take(key: string): Promise<null | unknown> {
    const value = await this.redis.getdel(key);
    return value ? JSON.parse(value) : null;
  }
}

Two requirements:

  • Honour ttl. Transactions and authorization codes are written with a TTL derived from their expiry. A backend that ignores it never reclaims them. Expired entries are still rejected on read, so this is a storage leak rather than a security hole — but an unbounded one.

  • Implement take. It must atomically return a value and delete it, so at most one caller ever observes it. Authorization codes, transactions and refresh-token mappings are single-use (RFC 6749 §4.1.2, §10.4) and the proxy consumes them through take. This matters even in a single process, because the check and the delete straddle an await. Without it the proxy falls back to a non-atomic get-then-delete, where two concurrent requests can both redeem the same authorization code, or both redeem the same refresh token and walk away with two independent token chains — which defeats rotation as a stolen-token tripwire.

    BackendPrimitive
    RedisGETDEL key
    DynamoDBDeleteItem with ReturnValues: "ALL_OLD"
    SQLDELETE FROM … WHERE key = \$1 RETURNING value

    MemoryTokenStorage and DiskStore both implement it.

Because codes are consumed at the start of the exchange, a token request that then fails validation — wrong client_id, bad code_verifier — spends the code. The client must restart at /oauth/authorize. That is deliberate: a code presented without its matching verifier has most likely leaked, so it should not stay redeemable.

Running multiple instances

Several instances behind a load balancer can serve different legs of the same OAuth flow, provided three things line up.

1. Share the storage. Every instance must point at the same backend, and it must implement take. MemoryTokenStorage is per-process, so it only ever works for a single instance.

2. Share the key material. consentSigningKey, encryptionKey, and jwtSigningKey are each generated per instance when absent. Two instances then write state the other cannot read, and the failures are indirect: decryption returns null, so a valid request surfaces as invalid_client or "Invalid or expired state" rather than as a key error. Set all three explicitly, from one secret source:

const authProxy = new OAuthProxy({
  // …
  consentSigningKey: process.env.OAUTH_CONSENT_SIGNING_KEY,
  encryptionKey: process.env.OAUTH_ENCRYPTION_KEY,
  jwtSigningKey: process.env.OAUTH_JWT_SIGNING_KEY,
  tokenStorage: new RedisTokenStorage(redisClient),
});

3. Keep clocks in sync. JWT expiry and transaction TTLs are absolute times.

JWKS verification

For asymmetric verification (RS256/ES256) or verifying tokens across services, use JWKSVerifier in place of the default HS256 issuer. It requires the optional jose peer dependency:

npm install jose
import { JWKSVerifier, OAuthProxy } from "@vitemcp/server/auth";

const authProxy = new OAuthProxy({
  // …
  tokenVerifier: new JWKSVerifier({
    audience: process.env.CLIENT_ID!,
    cacheDuration: 600000, // cache keys 10 minutes (default)
    cooldownDuration: 30000, // min 30s between refetches (default)
    issuer: "https://provider.com",
    jwksUri: "https://provider.com/.well-known/jwks.json",
  }),
});

It can also be used standalone:

const result = await verifier.verify(token);
if (result.valid) console.log(result.claims);
else console.log(result.error);

Keys are cached and the cooldown prevents hammering the JWKS endpoint during key rotation. jose is loaded lazily, so it costs nothing if you never instantiate a verifier.

Reach for JWKS when multiple services verify the same tokens, when you need asymmetric keys, or when your provider publishes a JWKS. Stay on the default HS256 issuer for a single server with no external verifiers — it is faster and needs no extra dependency.

Client registration

Dynamic Client Registration works, but is deprecated as of protocol revision 2026-07-28. Prefer Client ID Metadata Documents, where the client identifies itself with an HTTPS URL serving its own metadata and no registration step is needed.

If you keep CIMD enabled, set clientIdMetadata.allowedDomains — otherwise the server will fetch client-supplied URLs.

Security

Production checklist

  • HTTPS on all endpoints (baseUrl starts with https://)
  • Consent screen enabled (consentRequired: true)
  • Persistent storage (DiskStore or your own TokenStorage)
  • TokenStorage.take() implemented — single-use enforcement depends on it
  • encryptionKey supplied rather than auto-generated
  • jwtSigningKey and consentSigningKey supplied, derived from secrets, minimum 32 bytes
  • allowedRedirectUriPatterns configured
  • httpStream.allowedOrigins set if bound to a routable interface
  • clientIdMetadata.allowedDomains set, or CIMD disabled
  • upstreamIssuer set if the provider's issuer differs from its authorization endpoint's origin
  • Rate limiting in front of /oauth/*
  • Storage cleanup monitored, key-rotation procedure written down

Key management

Derive every key from a secret, and use a different key per purpose so that compromising one does not compromise the others:

const consentSigningKey = await JWTIssuer.deriveKey(
  process.env.SECRET! + ":consent",
  100000,
);
const encryptionKey = await JWTIssuer.deriveKey(
  process.env.SECRET! + ":storage",
  100000,
);
const jwtSigningKey = await JWTIssuer.deriveKey(
  process.env.SECRET! + ":jwt",
  100000,
);

Generate the underlying secret with real entropy:

node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"

Keep secrets in the environment, never in source.

What the proxy defends against

AttackMitigation
Confused deputyUser consent screen
Code interceptionTwo-tier PKCE, S256 only
Token theftShort-lived JWTs, encryption at rest
XSSHTML escaping in the consent screen
CSRFState parameter validation
ReplaySingle-use authorization codes
Directory traversalKey sanitization in DiskStore
Mix-upRFC 9207 iss validation on callback

To report a vulnerability, see SECURITY.md.

Limitations

  • Server-side proxy only; there is no client-side OAuth handler.
  • Proxy-issued JWTs are HS256 only. Use JWKSVerifier to verify RS256/ES256 tokens, but the proxy does not issue them.
  • No built-in token revocation endpoint.
  • No general-purpose distributed locking; single-use enforcement for authorization codes and transactions relies on TokenStorage.take.
  • Pre-configured providers cover Google, GitHub and Azure. Everything else goes through OAuthProvider or OAuthProxy, and some providers have quirks that need extraAuthorizationParams or upstreamIssuer.

Troubleshooting

"Invalid redirect URI" — the URI registered with your provider must be exactly {baseUrl}/oauth/callback, e.g. https://your-server.com/oauth/callback. Check for a trailing slash.

"Invalid state" — the transaction expired (10 minutes by default), the server restarted without persistent storage, key material differs between instances, or clocks are skewed.

"PKCE validation failed" — the client's code_verifier does not match the code_challenge it sent. Confirm the client stores the verifier across the redirect and sends the same one it derived the challenge from.

"Unsupported code_challenge_method" — the client asked for plain or an unrecognised method. Have it use S256; if it truly cannot, set allowPlainPkce: true and read the warning above first.

"Authorization response issuer does not match" — the provider returned an iss that is not the one this transaction started against (RFC 9207). If the provider's issuer legitimately differs from its endpoint origin, set upstreamIssuer.

Consent screen never appearsconsentRequired is false, a valid consent cookie is still set (clear cookies for the domain), or consentSigningKey changes between requests because it is auto-generated and you run more than one process.

Sessions vanish on restart — you are on the default MemoryTokenStorage. Switch to DiskStore or a shared backend.

Tokens expire immediately — TTLs are in seconds, not milliseconds.

No refresh_token from Google — add extraAuthorizationParams: { access_type: "offline" }.

Cannot find module '@vitemcp/server/auth' — both import { OAuthProxy } from "@vitemcp/server/auth" and from "@vitemcp/server" are valid; check the package is installed and your resolver understands the exports map ("moduleResolution": "bundler" or "node16").

Testing

npm test                              # everything
npm test -- src/auth/                 # OAuth only

To exercise a flow by hand, register a client, open the authorization URL in a browser, complete consent and provider login, then redeem the code:

curl -X POST http://localhost:3000/oauth/register \
  -H "Content-Type: application/json" \
  -d '{"client_name":"Test Client","redirect_uris":["http://localhost:8080/callback"]}'
http://localhost:3000/oauth/authorize?client_id=<client_id>&response_type=code&redirect_uri=http://localhost:8080/callback&code_challenge=<challenge>&code_challenge_method=S256
curl -X POST http://localhost:3000/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code&code=<code>&redirect_uri=http://localhost:8080/callback&code_verifier=<verifier>&client_id=<client_id>"

Examples

References