How do my customers authenticate to my MCP?

Every MCP you publish on CMD+K is a gateway: your customers connect with their own credentials, and every tool call reaches your API under that customer's identity — exactly like your normal API traffic. You choose the mode at publish time: per-customer API keys (recommended), per-customer OAuth, a signed webhook that lets your own backend approve each user, a shared token for internal use, or no auth for public data.

One principle: your API should know who's calling

An MCP behind a single shared token means every customer's AI calls your API as you — no data isolation, no attribution, no way to cut one customer off. CMD+K deployments are built the other way around: each customer connects with their own credential, and their calls reach your API as them. Your backend needs zero changes — requests look exactly like that customer calling your API directly.

The five modes

  • Customers use their own API keys (recommended). Each customer opens your MCP's connect page and pastes the API key they already have with your product. We validate it against your API, store it encrypted per customer, and forward it on every tool call. Works with any API that supports keys — no code changes.
  • Customers sign in with your OAuth. Same per-customer identity, via your OAuth server. Configure your authorize/token URLs and client ID in the Privacy tab; we run the full authorization-code flow (with PKCE) and refresh tokens automatically. A client secret is optional — see public clients below. Register our callback at your provider:
    https://commandplusk.com/oauth/d/<slug>/callback
  • Webhook Verify (your backend approves each user). The customer enters their email on your MCP's connect page; we POST an HMAC-signed JSON payload to a webhook you host, and your backend replies allow or deny. Use it when access is decided by your own records (active subscription, seat, role) rather than by a credential the customer holds. Note that in this mode tool calls reach your API with the workspace's stored setup token — the per-user identity is used for gating, attribution, scopes, and revocation, not as an upstream credential. Full details in Webhook Verify below.
  • Shared access token (internal/team edge case). All callers use the workspace's stored token — your API cannot tell them apart. Only for internal tools or read-only public data. Not for customer-facing MCPs.
  • No authentication. Anyone with the URL can call it (rate-limited). For demos and genuinely public data.

OAuth without a client secret (public / PKCE clients)

Only the authorize URL, token URL, and client ID are required to activate OAuth mode. The Client secret ref field is optional: leave it blank if your provider registered CMD+K as a public client. We always send a PKCE code_verifier with the authorization-code exchange, so a public client is secure without a secret. If you do store a secret (as a workspace secret referenced as workspace:NAME), we add client_secret to both the code exchange and refresh-token requests. Toward the AI client we advertise token_endpoint_auth_methods_supported: ["none", "client_secret_basic", "client_secret_post"], so public MCP clients work either way.

Webhook Verify

Webhook Verify hands the access decision to your backend. Nothing is validated against your API and no per-user credential is stored — instead, every connect attempt becomes one signed HTTP request to an endpoint you control, and your reply decides whether the user gets an MCP access token.

Dashboard fields

Open the deployment → Privacy tab → Authentication mode and choose Webhook Verify. Both fields are required before the deployment can be activated:

  • Webhook URL — a public https:// endpoint that accepts POST (private/loopback hosts are rejected).
  • Signing secret ref — a reference to a workspace secret in the form workspace:NAME. Store the raw secret under Workspace → Secrets; the same value is what your endpoint uses to verify signatures.

The card also shows your consent page URL:

https://commandplusk.com/mcp/d/<slug>/connect
Send customers there (or let the gateway's first unauthenticated response point them to it). The page accepts ?subject=<id> to pre-fill the subject field and ?client=<name> to pass a client hint through to your webhook.

What happens on connect

  1. The customer enters their email and an optional subject (a stable user ID; when blank, the email is used as the subject).
  2. We POST the signed payload below to your Webhook URL and wait up to 8 seconds.
  3. On allowed: true we upsert the end user (keyed by deployment + subject), mint a Bearer token, and show it once on the consent page together with the gateway endpoint https://commandplusk.com/api/public/mcp/d/<slug>. The customer pastes both into Claude, Cursor, or any MCP client.
  4. On allowed: false (or any error) the page shows your reason string — or a generic code such as webhook_status_401 — and no token is issued.

The request your endpoint receives

POST to your Webhook URL with these headers:

http
POST /cmdk/verify-user HTTP/1.1
content-type: application/json
x-cmdk-signature: t=<unix-ms>,v1=<hex(hmac_sha256)>
x-cmdk-event: mcp_deployment.verify_user
user-agent: CommandK-MCP-Gateway/1.0

And a JSON body with exactly these fields:

json
{
  "deployment_slug": "acme-crm",
  "subject": "user_123",
  "email": "jane@acme.com",
  "requested_scopes": ["read", "write"],
  "client_hint": "claude-desktop",
  "nonce": "8f1c…32 hex chars…",
  "timestamp": 1757318400123
}
  • subject — what the customer typed, or their email when left blank. You can override it in your reply.
  • requested_scopes — the scopes pre-configured for the connect link (may be empty). client_hint is a short free-text label of the MCP client, or null.
  • nonce is a random 32-character hex string and timestamp is Unix time in milliseconds; the same value appears as t= in the signature header.

Verifying the signature

The signature is HMAC-SHA256(secret, `${timestamp}.${rawBody}`) hex-encoded — the t value, a literal dot, then the request body bytes exactly as sent (the same scheme Stripe uses, so existing webhook tooling can be reused). Verify against the raw body, not a re-serialized copy, and reject stale timestamps to prevent replays; we recommend a 5-minute window.

js
import { createHmac, timingSafeEqual } from "node:crypto";

// Express — use express.raw() so you verify the exact bytes we signed.
app.post("/cmdk/verify-user", express.raw({ type: "application/json" }), async (req, res) => {
  const header = req.get("x-cmdk-signature") ?? "";            // "t=<unix-ms>,v1=<hex>"
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const ts = Number(parts.t);
  if (!Number.isFinite(ts) || Math.abs(Date.now() - ts) > 5 * 60_000) {
    return res.status(401).json({ allowed: false, reason: "stale_signature" });
  }

  const rawBody = req.body.toString("utf8");
  const expected = createHmac("sha256", process.env.CMDK_WEBHOOK_SECRET)
    .update(`${ts}.${rawBody}`)
    .digest("hex");
  const a = Buffer.from(parts.v1 ?? "", "hex");
  const b = Buffer.from(expected, "hex");
  if (a.length !== b.length || !timingSafeEqual(a, b)) {
    return res.status(401).json({ allowed: false, reason: "bad_signature" });
  }

  const { subject, email, requested_scopes } = JSON.parse(rawBody);
  const user = await findUserByEmail(email);                    // your own lookup
  if (!user || !user.active) {
    return res.json({ allowed: false, reason: "No active account for this email" });
  }
  return res.json({
    allowed: true,
    subject: user.id,             // optional — stable ID instead of the email
    display_name: user.name,      // optional
    scopes: requested_scopes,     // optional — narrow or replace
    ttl_seconds: 7 * 24 * 3600,   // optional — 60 s … 30 days, default 24 h
  });
});

What to respond

Reply with HTTP 2xx and a JSON object. allowed is the only required field; everything else is optional:

  • allowed (boolean, required) — grant or deny. A missing or non-boolean value is treated as a denial.
  • subject (string) — replaces the subject we sent; use it to map the email to your stable user ID so revocation and analytics key off it.
  • email (string | null), display_name (string | null) — stored on the end-user record shown in the Privacy tab.
  • scopes (string[]) — when non-empty, replaces requested_scopes on the issued token.
  • ttl_seconds (number) — lifetime of the issued token. Default 24 hours, clamped to 60 seconds – 30 days.
  • reason (string) — shown to the customer on the consent page when allowed is false.

Any non-2xx status, a body that isn't JSON, or a timeout counts as a denial (the customer sees webhook_status_<code>, webhook_invalid_json, or webhook_unreachable).

Test it from the dashboard

Once the URL and secret are saved, a Webhook inspector card appears under the authentication settings. It builds a signed sample request with your configured secret (so you can compare the header value locally) and can POST it to your endpoint. The request is built by the same code as production: identical body fields, a millisecond timestamp that matches the t= in the signature header, and the x-cmdk-event: mcp_deployment.verify_user header — so a verifier (including a strict replay window) that passes the inspector passes production. The only difference is an extra x-cmdk-sample: 1 header plus placeholder values (subject sample-user by default, email sample-user@example.com, client_hint dashboard-inspector). Treat that header as a dry run: verify the HMAC, but never grant access to a request carrying it.

What your customer sees

  • They point their MCP client (Claude, Cursor, MCP Inspector) at your MCP URL. The first call answers with a link to your connect page.
  • At
    https://commandplusk.com/mcp/d/<slug>/connect
    they enter their email and their API key (or complete your OAuth flow, or — in Webhook Verify mode — wait for your backend's approval).
  • We validate the credential against your API, then issue a personal MCP access token they paste into their client. From that moment, every tool call runs as them.

Your setup token is not a runtime credential

The token you enter in the wizard's Source step is used to scan your API's schema and re-sync it later — in per-customer modes it is never used for customer calls. Treat the "shared access token" mode as the exception, not the default.

Using your hosted MCP as an assistant backend

The same hosted MCP can power a CMD+K assistant on your own site. In the assistant's "Connect your backend" step pick the hosted MCP; the assistant then mints a short-lived visitor JWT per signed-in user (the audience and signing secret shown on that connection) and sends it to your MCP URL. Our gateway accepts that JWT as the visitor's session — no connect page, no pasted token — and forwards it to your API as

Authorization: Bearer
when the source connection is set to require end-user identity (or has no stored credentials of its own). Verify it in your backend exactly like any other visitor token. If the source connection instead holds a shared API key, that key is used and the visitor identity is not forwarded.

Keep the connection, keep the secret

The audience and signing secret belong to the connection row. Removing the connection and re-adding it generates new values; use "Disconnect" or "Update credentials" instead.

Visibility and revocation

Every connected customer appears in the deployment's Privacy tab with last-seen time. Revoking a customer takes effect on their next call. Every tool call is logged with the (deployment, tool, customer) tuple — analytics, allowlists, and Monetize meters key off that identity.

Next steps

FAQ

Where do customers enter their API key?
On your MCP's hosted connect page at commandplusk.com/mcp/d/<slug>/connect. They enter their email and the API key they already use with your product. We validate the key against your API before accepting it, then issue them a personal MCP access token.
Where are customer credentials stored?
Encrypted at rest, per customer, in our managed Postgres. They are decrypted only at call time to sign the upstream request. You can revoke any customer from the dashboard — revocation blocks their calls immediately.
Does the setup token I entered during scanning get used for customer calls?
No. In per-customer modes your setup token is only used to scan your API's schema and re-sync it later. Customer calls always carry the customer's own credential. Only the 'shared access token' mode uses your stored token at runtime — which is why it's reserved for internal use.
My API uses OAuth, not API keys. Can customers sign in with that?
Yes — pick OAuth mode. CMD+K acts as an OAuth 2.1 authorization server toward the AI client (dynamic client registration + PKCE) and as an OAuth client toward your provider. Customers authorize on your login page; we store and refresh their tokens per customer and forward them on every call.
Can my own backend decide who is allowed to connect?
Yes — pick Webhook Verify mode. When a user submits the connect page we POST a JSON payload (deployment_slug, subject, email, requested_scopes, client_hint, nonce, timestamp) to your webhook URL, signed with HMAC-SHA256 in the x-cmdk-signature header. Reply { allowed: true } (optionally with subject, display_name, scopes, ttl_seconds) to grant a token, or { allowed: false, reason } to deny.
Does OAuth mode require a client secret?
No. The client secret ref is optional. Leave it blank for a public client — we still run the authorization-code flow with PKCE and send only client_id (plus the code_verifier) to your token endpoint. Set it for confidential clients; we then add client_secret to the code exchange and refresh requests.
What happens when a customer's access should end?
Open the deployment's Privacy tab → Connected end users → Revoke. Their sessions stop working on the next call. If they reconnect and you've disabled their account on your side, their key/OAuth grant will fail validation too.