Identify your users
Attribute MCP sessions to your end users and accounts with JWT claim mapping or a resolver endpoint. Tokens never leave your infrastructure.
Spanly can tie every captured session to the end user (and account) behind it. Once configured you get:
- Sessions attributed to users: the sessions list and session detail show who drove each conversation.
- A Users page listing everyone who used your MCP server, with sessions, requests, errors, and last-seen per user, plus a per-user detail view.
- Filtering: scope any session view to a single user.
The quickest path: JWT claim mapping
If your MCP server authenticates with JWT bearer tokens, identity is one flag on the Spanly CLI proxy:
spanly run --port 3000 --identity-jwt -- node server.jsThe proxy decodes the Authorization: Bearer JWT payload and maps
sub to the user id, email to email, and name to name. Override any
mapping with --identity-jwt-claim (repeatable, dot-paths supported):
spanly run --port 3000 \
--identity-jwt \
--identity-jwt-claim accountId=org_id \
--identity-jwt-claim id=user.id \
-- node server.jsThe token is decoded only, never signature-verified: your server behind the proxy has already authenticated the request, so the claims are only used to label traffic it accepted.
Using the TypeScript SDK middleware instead of the CLI? The same tiers are
available in-process: spanly({ identity: { jwtClaims: true } }), an
identity.resolve callback, or identity.resolver. See the
SDK API reference for the SDK
shapes; the rest of this page uses the CLI flags.
Opaque tokens: the resolver endpoint
If your tokens are opaque (API keys, session tokens), host a small HTTP endpoint that maps a token to a user, and point the proxy at it:
spanly run --port 3000 \
--identity-resolver-url=https://api.example.com/spanly-identity \
--identity-resolver-secret=$SPANLY_IDENTITY_SECRET \
-- node server.jsThe endpoint can be one route on the MCP server itself; any URL works. The full request/response contract, caching behavior, and a copy-paste Express handler are in the resolver protocol reference.
When both tiers are configured, a JWT that decodes to a user id wins and the resolver is never called; opaque tokens fall through to the resolver.
Deployment model
Identity is resolved by the producer running inside your infrastructure:
the Spanly CLI proxy (spanly run --port or spanly proxy) in front of
your HTTP MCP server, or the TypeScript SDK middleware in-process. stdio
mode has no auth headers, so identity does not apply there.
Privacy model
Your users' tokens never leave your infrastructure. The JWT decode happens
inside the proxy process, and the resolver endpoint is yours: Spanly's
backend never sees the token and never calls your resolver. What Spanly
receives is only the resolved id, email, name, accountId, and
accountName you choose to send, plus a one-way token fingerprint (the
first 32 hex characters of the SHA-256 of the bearer credential) that you
can disable with --no-auth-fingerprint.
Token rotation
Attribution keys on your stable user id, so rotating tokens do not fragment users: a new token re-resolves to the same id and the sessions line up under one user. The fingerprint-only fallback (no identity configured) counts distinct tokens, so it over-counts users under rotation. That is why the Users page shows "distinct auth tokens" rather than "users" until identity is configured.
What to return
| Field | Required | Use |
|---|---|---|
id | yes | Your internal user id. Stable across logins and tokens. |
email | no | Display in the dashboard. |
name | no | Display in the dashboard. |
accountId | no | Company or workspace level attribution. |
accountName | no | Display for the account. |
Do not use the raw token or a rotating claim (jti, exp-derived values)
as id: every rotation would then look like a new user, which defeats the
point. An email works for display but makes a poor id, since users change
emails.
CLI flag reference
| Flag | Default | Description |
|---|---|---|
--identity-jwt | off | Decode the bearer JWT with the default mapping: id from sub, email from email, name from name. |
--identity-jwt-claim | none | field=claim override, repeatable. Fields: id, email, name, accountId, accountName. Dot-paths OK. Implies --identity-jwt. |
--identity-resolver-url | none | URL of your resolver endpoint (see the protocol reference). |
--identity-resolver-secret | none | Shared secret sent as X-Spanly-Identity-Secret. Also via SPANLY_IDENTITY_RESOLVER_SECRET; the flag wins. |
--no-auth-fingerprint | off | Disable the anonymous token fingerprint (on by default in HTTP mode). |
Behavior reference
| Behavior | Value |
|---|---|
| Positive cache | 15 minutes per token |
| Negative cache | 60 seconds per token (unknown tokens and resolver failures) |
| Resolver timeout | 3 seconds, no retries (natural retry after negative cache expiry) |
| Concurrency | One in-flight resolution per token |
| Failure mode | Fail-open: resolution failure never affects MCP serving |
| Fingerprint scheme | Bearer only; Basic and other schemes are never fingerprinted |