Identity Brokering — Auth Flow

Identity Brokering uses a standard interactive OAuth flow: the AI client opens a browser, the user signs in to LockMCP, and the client receives a token it presents on every tool call. Sign-in and token verification never touch your infrastructure — but if this connection's tools call your own API, that call does. See Verifying the forwarded token in your own API below.

This page applies only to connections using the Identity Brokering identity model.

Sequence

┌──────────────┐        ┌──────────────┐        ┌────────────┐
│  AI Client   │        │   LockMCP    │        │  LockMCP   │
│ (Claude etc) │        │   Identity   │        │ Data Plane │
└──────────────┘        └──────────────┘        └────────────┘
       │                       │                      │
       │  1. Tool request, no credentials             │
       │ ────────────────────────────────────────────►│
       │                       │                      │
       │  2. 401 + where to authenticate              │
       │  ◄────────────────────────────────────────── │
       │                       │                      │
       │  3. Open browser, start OAuth                │
       │ ─────────────────────►│                      │
       │                       │                      │
       │  4. User signs in to LockMCP                 │
       │                       │                      │
       │  5. Token issued (roles, tenant, permissions)│
       │  ◄─────────────────── │                      │
       │                       │                      │
       │  6. Tool request, Bearer <token>             │
       │ ────────────────────────────────────────────►│
       │                       │                      │
       │           7. Verify signature (RS256)        │
       │           8. Resolve user from `sub`         │
       │           9. Load roles → permitted tools    │
       │                       │                      │
       │  10. Tool result (or 403 if not permitted)   │
       │  ◄────────────────────────────────────────── │

Step by step

1–2 · Discovery. The AI client calls the MCP endpoint with no credentials and gets a 401 telling it where to authenticate. Clients that speak MCP handle this automatically — it's why "paste the URL and sign in" is the whole setup story.

3–4 · Interactive sign-in. The client opens a browser to LockMCP's sign-in page. The user signs in. Because the connection's browser sign-in flow is enabled (see Configuration), this completes the way any OAuth login would.

5 · Token issued. LockMCP issues an RS256 JWT carrying the claims added by the connection's claims scope — the user's roles, their tenant, and their permissions.

6–7 · The tool call. The client presents the token as a Bearer credential. The data plane verifies the signature against LockMCP's issuer — a single, fixed issuer — and requires exp, iss and sub.

8 · User resolution. The verified sub is looked up to resolve the tenant and user. If there's no matching record:

Authentication failed: user not found — please log in to the Dashboard first

9 · Authorization. The user's roles determine which published tools they can see and call. Role-based access is enforced in the query itself, so a user is never handed a tool they aren't permitted to call — the tool list they receive is already filtered.

10 · Execution. LockMCP proxies the call to your upstream API, forwarding the same verified token the user authenticated with, and returns the result. Your API must be able to verify that token itself — see below.

Why desktop AI clients work here

Claude Desktop, Cursor, Windsurf and ChatGPT connectors all expect to open a browser and complete an OAuth login. Identity Brokering is exactly that flow, so those clients work by pasting the MCP endpoint URL — no config file, no API keys, no manual token handling.

Token Exchange connections deliberately disable this browser flow, which is why they need programmatic integration instead. If desktop clients matter to you, Identity Brokering is the model to use.

What this means in practice

QuestionAnswer
Do I host anything?Only if this connection's tools call your own API (the common case) — that API must verify the forwarded token. Sign-in, token issuance and user records are all handled by LockMCP either way.
How do I offboard someone?Remove them under Members & Access. Their access ends.
Does every tool call re-authenticate the user?No — the token is verified locally on each call. No round trip to a login server.
What happens when a token expires?The client re-runs the OAuth flow. Well-behaved MCP clients do this transparently.
Can I see who called what?Yes — tool calls are attributed to the resolved user and recorded in the audit trail.

Give this to your coding agent

Everything below this point — token verification, authorization, and mapping LockMCP identities to your own users — is implementation work in your codebase, in whatever language and framework it happens to use. Getting it exactly right by hand, across three separate concerns, is easy to get subtly wrong (a mismatched issuer, a role check that can never pass, a foreign key with nothing to reference) — the kind of mistake a human makes once per concern, not because the requirements are unclear but because translating them into working code is tedious and easy to skip a step on.

Copy the prompt below and give it to Claude Code, Codex, or any AI coding agent working in your API's repo. It has the full contract embedded — env var, requirements, and working reference code in four languages for each concern — so there's nothing to go fetch separately; the agent just needs to be pointed at your codebase to adapt the matching-language sample to it.

Fill in your connection's actual issuer URL (shown on this connection's Connect page in the dashboard) before sending the prompt — it's marked <YOUR_ISSUER_URL> everywhere below.

I'm integrating my API with LockMCP, an MCP connector hub, using its "Identity Brokering"
identity model. LockMCP forwards my authenticated users' own token to my API as a standard
bearer credential on every request:

  Authorization: Bearer <token>

No other header identifies the caller or connection — everything needed is in the token's own
claims. Please inspect my existing codebase (its auth middleware, ORM/database layer, and
conventions) and implement the following, adapting the matching-language reference code below
to fit my actual stack rather than pasting it in verbatim:

=== 0. ENVIRONMENT SETUP ===

Add one environment variable my code will read the issuer from:

  LOCKMCP_ISSUER=<YOUR_ISSUER_URL>

Add it to my .env / .env.example, and tell me explicitly to also set it as a real environment
variable on whatever platform actually hosts this API (Render, Vercel, Fly, etc.) — adding it
to a local .env file alone does nothing for the deployed app.

=== 1. TOKEN VERIFICATION ===

Add real JWT verification to every endpoint any of my API's tools call (audit all of them, not
just the first one):
  - Read the bearer token from the standard Authorization header.
  - Verify the RS256 signature against the JWKS at `<YOUR_ISSUER_URL>/protocol/openid-connect/certs`.
  - Require the token's `iss` claim to equal `<YOUR_ISSUER_URL>` exactly (string match).
  - Require `exp` and `sub` to be present; reject an expired token.
  - Do NOT require or check the `aud` claim — the hub itself does not enforce it, so requiring
    it here would reject valid tokens.
  - On success, treat `sub` as the caller's LockMCP user id.

Reference implementation — pick whichever matches my stack, or adapt the pattern if none do:

--- Node.js (TypeScript) ---
```typescript
// middleware/verifyLockMcpToken.ts
import jwt, { type JwtPayload } from "jsonwebtoken";
import jwksClient from "jwks-rsa";
import type { Request, Response, NextFunction } from "express";

const EXPECTED_ISSUER = process.env.LOCKMCP_ISSUER ?? "";

const jwks = jwksClient({
  jwksUri: `${EXPECTED_ISSUER}/protocol/openid-connect/certs`,
  cache: true,
  cacheMaxAge: 10 * 60 * 1000,
});

function getKey(header: jwt.JwtHeader, callback: jwt.SigningKeyCallback) {
  jwks.getSigningKey(header.kid, (err, key) => {
    callback(err, key?.getPublicKey());
  });
}

export function verifyLockMcpToken(req: Request, res: Response, next: NextFunction) {
  const auth = req.headers.authorization ?? "";
  const [scheme, token] = auth.split(" ");
  if (scheme !== "Bearer" || !token) {
    return res.status(401).json({ success: false, message: "Missing bearer token" });
  }

  jwt.verify(
    token,
    getKey,
    { algorithms: ["RS256"], issuer: EXPECTED_ISSUER },
    (err, decoded) => {
      if (err || !decoded || typeof decoded === "string") {
        return res.status(401).json({ success: false, message: "Invalid or expired token" });
      }
      const claims = decoded as JwtPayload;
      if (!claims.sub || !claims.exp) {
        return res.status(401).json({ success: false, message: "Invalid or expired token" });
      }
      (req as any).lockMcpUserId = claims.sub;
      next();
    },
  );
}
```

--- Node.js (JavaScript) ---
```javascript
// middleware/verifyLockMcpToken.js
const jwt = require("jsonwebtoken");
const jwksClient = require("jwks-rsa");

const EXPECTED_ISSUER = process.env.LOCKMCP_ISSUER ?? "";

const jwks = jwksClient({
  jwksUri: `${EXPECTED_ISSUER}/protocol/openid-connect/certs`,
  cache: true,
  cacheMaxAge: 10 * 60 * 1000,
});

function getKey(header, callback) {
  jwks.getSigningKey(header.kid, (err, key) => {
    callback(err, key && key.getPublicKey());
  });
}

function verifyLockMcpToken(req, res, next) {
  const auth = req.headers.authorization ?? "";
  const [scheme, token] = auth.split(" ");
  if (scheme !== "Bearer" || !token) {
    return res.status(401).json({ success: false, message: "Missing bearer token" });
  }

  jwt.verify(
    token,
    getKey,
    { algorithms: ["RS256"], issuer: EXPECTED_ISSUER },
    (err, decoded) => {
      if (err || !decoded || !decoded.sub || !decoded.exp) {
        return res.status(401).json({ success: false, message: "Invalid or expired token" });
      }
      req.lockMcpUserId = decoded.sub;
      next();
    },
  );
}

module.exports = { verifyLockMcpToken };
```

--- Python ---
```python
# middleware/verify_lockmcp_token.py
import os
from functools import wraps

import jwt
from flask import request, jsonify
from jwt import PyJWKClient

EXPECTED_ISSUER = os.environ.get("LOCKMCP_ISSUER", "")
_jwks_client = PyJWKClient(f"{EXPECTED_ISSUER}/protocol/openid-connect/certs")


def verify_lockmcp_token(fn):
    @wraps(fn)
    def wrapper(*args, **kwargs):
        auth = request.headers.get("Authorization", "")
        scheme, _, token = auth.partition(" ")
        if scheme != "Bearer" or not token:
            return jsonify(success=False, message="Missing bearer token"), 401
        try:
            signing_key = _jwks_client.get_signing_key_from_jwt(token)
            claims = jwt.decode(
                token,
                signing_key.key,
                algorithms=["RS256"],
                issuer=EXPECTED_ISSUER,
                options={"require": ["exp", "iss", "sub"], "verify_aud": False},
            )
        except jwt.PyJWTError:
            return jsonify(success=False, message="Invalid or expired token"), 401

        request.lockmcp_user_id = claims["sub"]
        return fn(*args, **kwargs)

    return wrapper
```

--- Java ---
```java
// LockMcpTokenVerifier.java
import com.auth0.jwk.Jwk;
import com.auth0.jwk.JwkProvider;
import com.auth0.jwk.JwkProviderBuilder;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;

import java.security.interfaces.RSAPublicKey;
import java.util.concurrent.TimeUnit;

public class LockMcpTokenVerifier {
    private final String expectedIssuer;
    private final JwkProvider jwkProvider;

    public LockMcpTokenVerifier(String expectedIssuer) {
        this.expectedIssuer = expectedIssuer;
        this.jwkProvider = new JwkProviderBuilder(expectedIssuer + "/protocol/openid-connect/certs")
            .cached(10, 10, TimeUnit.MINUTES)
            .build();
    }

    /** Returns the verified token's `sub` claim (the caller's LockMCP user id), or throws if invalid. */
    public String verify(String bearerToken) throws Exception {
        DecodedJWT unverified = JWT.decode(bearerToken);
        Jwk jwk = jwkProvider.get(unverified.getKeyId());
        Algorithm algorithm = Algorithm.RSA256((RSAPublicKey) jwk.getPublicKey(), null);

        DecodedJWT verified = JWT.require(algorithm)
            .withIssuer(expectedIssuer)
            .build()
            .verify(bearerToken);

        if (verified.getSubject() == null || verified.getExpiresAt() == null) {
            throw new IllegalArgumentException("Missing sub/exp claim");
        }
        return verified.getSubject();
    }
}
```

=== 2. AUTHORIZATION ===

Do NOT gate access on the token's role claim (`realm_access.roles` or a custom `roles` claim).
That claim is empty for real users today — a current platform limitation, not something my code
is doing wrong. Instead: if a request reaches my API at all, LockMCP has already authorized this
specific user to call this specific tool under its own access control, before my API was ever
called — treat a verified token as sufficient authorization for whatever this endpoint does.
This is guidance, not something to write code for — the fix is to NOT add a role check here.
Only add an independent role/permission check on my side if I explicitly need defense-in-depth
beyond what LockMCP already enforces, and if so, key it off the token's `sub` claim (the one
stable identifier always present), using a mapping I maintain myself.

=== 3. USER MAPPING ===

If any endpoint these tools call writes a foreign key referencing "the acting user" (e.g. a
column like agentId, createdBy, ownerId, userId on a row being created), find or create a local
user row for this LockMCP identity right after verification succeeds, and use that row's local
id for the foreign key — never the raw `sub`. Key the lookup on the token's `email` claim
against whatever column my existing user table already treats as unique (usually `email`) — do
not add a new column or migration unless my schema genuinely has no unique user-identifying
column already. If `email` is absent from the token, fall back to a stable placeholder derived
from `sub` (e.g. `${sub}@lockmcp.local`). Give any newly-created row a random/placeholder
password — it must never be a real, login-capable local account.

Reference implementation — pick whichever matches my stack, or adapt the pattern if none do:

--- Node.js (TypeScript) ---
```typescript
// Run this inside your token-verification middleware, after the JWT is verified,
// using whatever ORM/query layer your app already has (this example uses Prisma).
interface LockMcpClaims {
  sub: string;
  email?: string;
  preferred_username?: string;
  given_name?: string;
  family_name?: string;
}

async function resolveLocalUser(decoded: LockMcpClaims) {
  const lockMcpEmail = decoded.email || decoded.preferred_username || `${decoded.sub}@lockmcp.local`;

  const localUser = await prisma.user.upsert({
    where: { email: lockMcpEmail },
    update: {},
    create: {
      email: lockMcpEmail,
      password: crypto.randomUUID(), // placeholder — never used to log in locally
      firstName: decoded.given_name ?? 'LockMCP',
      lastName: decoded.family_name ?? 'User',
      role: 'AGENT', // or whatever your default role should be
    },
  });

  return { id: localUser.id, email: localUser.email, lockMcpUserId: decoded.sub };
}
```

--- Node.js (JavaScript) ---
```javascript
// Run this inside your token-verification middleware, after the JWT is verified,
// using whatever ORM/query layer your app already has (this example uses Prisma).
const lockMcpEmail = decoded.email || decoded.preferred_username || `${decoded.sub}@lockmcp.local`;

const localUser = await prisma.user.upsert({
  where: { email: lockMcpEmail },
  update: {},
  create: {
    email: lockMcpEmail,
    password: crypto.randomUUID(), // placeholder — never used to log in locally
    firstName: decoded.given_name || 'LockMCP',
    lastName: decoded.family_name || 'User',
    role: 'AGENT', // or whatever your default role should be
  },
});

req.user = { id: localUser.id, email: localUser.email, lockMcpUserId: decoded.sub };
```

--- Python ---
```python
# Run this right after JWT verification succeeds, using whatever ORM your app has
# (this example uses SQLAlchemy-style get-or-create; adapt to your own layer).
lock_mcp_email = claims.get("email") or claims.get("preferred_username") or f"{claims['sub']}@lockmcp.local"

local_user = get_user_by_email(lock_mcp_email)
if local_user is None:
    local_user = create_user(
        email=lock_mcp_email,
        password=secrets.token_urlsafe(32),  # placeholder — never used to log in locally
        first_name=claims.get("given_name", "LockMCP"),
        last_name=claims.get("family_name", "User"),
        role="AGENT",  # or whatever your default role should be
    )

request.state.user = {"id": local_user.id, "email": local_user.email, "lock_mcp_user_id": claims["sub"]}
```

--- Java ---
```java
// Run this right after JWT verification succeeds, using whatever persistence layer
// your app has (this example uses JPA/Hibernate).
public class LockMcpUserResolver {
    private final EntityManager entityManager;

    public LockMcpUserResolver(EntityManager entityManager) {
        this.entityManager = entityManager;
    }

    @Transactional
    public LocalUser resolve(DecodedJWT verified) {
        String email = verified.getClaim("email").asString();
        if (email == null) email = verified.getClaim("preferred_username").asString();
        if (email == null) email = verified.getSubject() + "@lockmcp.local";

        LocalUser existing = entityManager
            .createQuery("SELECT u FROM LocalUser u WHERE u.email = :email", LocalUser.class)
            .setParameter("email", email)
            .getResultStream()
            .findFirst()
            .orElse(null);
        if (existing != null) return existing;

        LocalUser created = new LocalUser();
        created.setEmail(email);
        created.setPassword(java.util.UUID.randomUUID().toString());
        created.setFirstName(java.util.Optional.ofNullable(verified.getClaim("given_name").asString()).orElse("LockMCP"));
        created.setLastName(java.util.Optional.ofNullable(verified.getClaim("family_name").asString()).orElse("User"));
        created.setRole("AGENT");
        entityManager.persist(created);
        return created;
    }
}
```

=== FINALLY ===

Look at how my project currently structures auth/middleware and database access, and implement
all of the above following those existing patterns rather than pasting the reference code in
verbatim. Remind me explicitly that none of this takes effect until actually deployed — a
local-only change has no effect on what LockMCP calls until it's pushed and deployed.

(For full context, this prompt is generated from the "Identity Brokering — Auth Flow" page
under Docs in the LockMCP dashboard.)

Verifying the forwarded token in your own API

If any of this connection's tools call your own API — the common case — LockMCP forwards the same token it just verified, as a standard bearer credential:

Authorization: Bearer <token>

No other header identifies the caller or the connection; everything your API needs is inside the token's own claims. Your API must independently verify it before trusting the request — the same check LockMCP's data plane already performs, so your check should never be stricter than what's below or you risk rejecting tokens LockMCP itself accepts:

CheckRequirement
SignatureRS256, verified against the JWKS at {iss}/protocol/openid-connect/certs
Issuer (iss)Must equal this hub's fixed issuer exactly — don't accept any issuer that merely "looks right"
Expiry / subjectexp and sub must be present; reject an expired token
Audience (aud)Not enforced by LockMCP itself. Don't hard-require it — doing so may reject tokens LockMCP accepts. Treat aud/azp as informational only.

Get the issuer right for your environment. A token minted by one LockMCP environment (e.g. a local/dev instance) has a different iss than one minted by production — verifying against the wrong issuer fails every request with a generic "invalid token" error that looks identical to having no verification at all.

On success, sub is the caller's LockMCP user id — use it for any authorization decisions your own API needs to make.

typescript
// middleware/verifyLockMcpToken.ts
import jwt, { type JwtPayload } from "jsonwebtoken";
import jwksClient from "jwks-rsa";
import type { Request, Response, NextFunction } from "express";

const EXPECTED_ISSUER = process.env.LOCKMCP_ISSUER ?? "";

const jwks = jwksClient({
  jwksUri: `${EXPECTED_ISSUER}/protocol/openid-connect/certs`,
  cache: true,
  cacheMaxAge: 10 * 60 * 1000,
});

function getKey(header: jwt.JwtHeader, callback: jwt.SigningKeyCallback) {
  jwks.getSigningKey(header.kid, (err, key) => {
    callback(err, key?.getPublicKey());
  });
}

export function verifyLockMcpToken(req: Request, res: Response, next: NextFunction) {
  const auth = req.headers.authorization ?? "";
  const [scheme, token] = auth.split(" ");
  if (scheme !== "Bearer" || !token) {
    return res.status(401).json({ success: false, message: "Missing bearer token" });
  }

  jwt.verify(
    token,
    getKey,
    { algorithms: ["RS256"], issuer: EXPECTED_ISSUER },
    (err, decoded) => {
      if (err || !decoded || typeof decoded === "string") {
        return res.status(401).json({ success: false, message: "Invalid or expired token" });
      }
      const claims = decoded as JwtPayload;
      if (!claims.sub || !claims.exp) {
        return res.status(401).json({ success: false, message: "Invalid or expired token" });
      }
      (req as any).lockMcpUserId = claims.sub;
      next();
    },
  );
}
javascript
// middleware/verifyLockMcpToken.js
const jwt = require("jsonwebtoken");
const jwksClient = require("jwks-rsa");

const EXPECTED_ISSUER = process.env.LOCKMCP_ISSUER ?? "";

const jwks = jwksClient({
  jwksUri: `${EXPECTED_ISSUER}/protocol/openid-connect/certs`,
  cache: true,
  cacheMaxAge: 10 * 60 * 1000,
});

function getKey(header, callback) {
  jwks.getSigningKey(header.kid, (err, key) => {
    callback(err, key && key.getPublicKey());
  });
}

function verifyLockMcpToken(req, res, next) {
  const auth = req.headers.authorization ?? "";
  const [scheme, token] = auth.split(" ");
  if (scheme !== "Bearer" || !token) {
    return res.status(401).json({ success: false, message: "Missing bearer token" });
  }

  jwt.verify(
    token,
    getKey,
    { algorithms: ["RS256"], issuer: EXPECTED_ISSUER },
    (err, decoded) => {
      if (err || !decoded || !decoded.sub || !decoded.exp) {
        return res.status(401).json({ success: false, message: "Invalid or expired token" });
      }
      req.lockMcpUserId = decoded.sub;
      next();
    },
  );
}

module.exports = { verifyLockMcpToken };
python
# middleware/verify_lockmcp_token.py
import os
from functools import wraps

import jwt
from flask import request, jsonify
from jwt import PyJWKClient

EXPECTED_ISSUER = os.environ.get("LOCKMCP_ISSUER", "")
_jwks_client = PyJWKClient(f"{EXPECTED_ISSUER}/protocol/openid-connect/certs")


def verify_lockmcp_token(fn):
    @wraps(fn)
    def wrapper(*args, **kwargs):
        auth = request.headers.get("Authorization", "")
        scheme, _, token = auth.partition(" ")
        if scheme != "Bearer" or not token:
            return jsonify(success=False, message="Missing bearer token"), 401
        try:
            signing_key = _jwks_client.get_signing_key_from_jwt(token)
            claims = jwt.decode(
                token,
                signing_key.key,
                algorithms=["RS256"],
                issuer=EXPECTED_ISSUER,
                options={"require": ["exp", "iss", "sub"], "verify_aud": False},
            )
        except jwt.PyJWTError:
            return jsonify(success=False, message="Invalid or expired token"), 401

        request.lockmcp_user_id = claims["sub"]
        return fn(*args, **kwargs)

    return wrapper
java
// LockMcpTokenVerifier.java
import com.auth0.jwk.Jwk;
import com.auth0.jwk.JwkProvider;
import com.auth0.jwk.JwkProviderBuilder;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;

import java.security.interfaces.RSAPublicKey;
import java.util.concurrent.TimeUnit;

public class LockMcpTokenVerifier {
    private final String expectedIssuer;
    private final JwkProvider jwkProvider;

    public LockMcpTokenVerifier(String expectedIssuer) {
        this.expectedIssuer = expectedIssuer;
        this.jwkProvider = new JwkProviderBuilder(expectedIssuer + "/protocol/openid-connect/certs")
            .cached(10, 10, TimeUnit.MINUTES)
            .build();
    }

    /** Returns the verified token's `sub` claim (the caller's LockMCP user id), or throws if invalid. */
    public String verify(String bearerToken) throws Exception {
        DecodedJWT unverified = JWT.decode(bearerToken);
        Jwk jwk = jwkProvider.get(unverified.getKeyId());
        Algorithm algorithm = Algorithm.RSA256((RSAPublicKey) jwk.getPublicKey(), null);

        DecodedJWT verified = JWT.require(algorithm)
            .withIssuer(expectedIssuer)
            .build()
            .verify(bearerToken);

        if (verified.getSubject() == null || verified.getExpiresAt() == null) {
            throw new IllegalArgumentException("Missing sub/exp claim");
        }
        return verified.getSubject();
    }
}

Authorization: don't rely on the token's role claim

Verifying the token (above) proves who is calling. It does not tell you what they're allowed to do — for that, don't reach for realm_access.roles or the roles claim.

Today, that claim is empty for real users on every connection. This is a current platform limitation, not something wrong in your verification code — a real, successfully-authenticated token looks like this:

realm_access: { roles: ["default-roles-mcp-hub", "offline_access", "uma_authorization"] }

No application-specific role ever appears there yet, no matter how the connection or its users are configured.

Use LockMCP's own access control as your authorization boundary instead. If a tool call reached your API at all, LockMCP has already decided this user may invoke it — that decision is made under Members & Access (who has a role on this connection, and which published tools that role can call), before your API is ever called. A second role check on your side that depends on the token's role claim doesn't add a layer of security today — it just fails closed for every request, since the claim it's checking is always empty.

If you need genuinely independent, defense-in-depth authorization on your own side, the token's sub claim is the one stable identifier always present — maintain your own mapping from sub to whatever role or permission model your API needs, since the platform doesn't supply one yet.

Mapping LockMCP identities to your own users

If any endpoint your tools call writes a foreign key that references "the acting user" — agentId, createdBy, ownerId, and similar are common names — that foreign key needs a real row in your own user table. A LockMCP identity has no such row by default: nothing pre-creates one, and the token's sub is a Keycloak id your database has never seen.

The fix is a find-or-create, run right after token verification succeeds and before any application logic that needs a local user id:

Key it on the token's email claim against whatever column your user table already treats as unique — usually email — rather than adding a new column. Most user tables already have one, so this needs no schema change. If email is ever absent from a token, fall back to a stable placeholder derived from sub (e.g. ${sub}@lockmcp.local) rather than skipping the lookup.

typescript
// Run this inside your token-verification middleware, after the JWT is verified,
// using whatever ORM/query layer your app already has (this example uses Prisma).
interface LockMcpClaims {
  sub: string;
  email?: string;
  preferred_username?: string;
  given_name?: string;
  family_name?: string;
}

async function resolveLocalUser(decoded: LockMcpClaims) {
  const lockMcpEmail = decoded.email || decoded.preferred_username || `${decoded.sub}@lockmcp.local`;

  const localUser = await prisma.user.upsert({
    where: { email: lockMcpEmail },
    update: {},
    create: {
      email: lockMcpEmail,
      password: crypto.randomUUID(), // placeholder — never used to log in locally
      firstName: decoded.given_name ?? 'LockMCP',
      lastName: decoded.family_name ?? 'User',
      role: 'AGENT', // or whatever your default role should be
    },
  });

  // Use localUser.id (not decoded.sub) anywhere your API needs a real local user id —
  // e.g. the acting-user foreign key on a new record.
  return { id: localUser.id, email: localUser.email, lockMcpUserId: decoded.sub };
}
javascript
// Run this inside your token-verification middleware, after the JWT is verified,
// using whatever ORM/query layer your app already has (this example uses Prisma).
const lockMcpEmail = decoded.email || decoded.preferred_username || `${decoded.sub}@lockmcp.local`;

const localUser = await prisma.user.upsert({
  where: { email: lockMcpEmail },
  update: {},
  create: {
    email: lockMcpEmail,
    password: crypto.randomUUID(), // placeholder — never used to log in locally
    firstName: decoded.given_name || 'LockMCP',
    lastName: decoded.family_name || 'User',
    role: 'AGENT', // or whatever your default role should be
  },
});

// Use localUser.id (not decoded.sub) anywhere your API needs a real local user id —
// e.g. the acting-user foreign key on a new record.
req.user = { id: localUser.id, email: localUser.email, lockMcpUserId: decoded.sub };
python
# Run this right after JWT verification succeeds, using whatever ORM your app has
# (this example uses SQLAlchemy-style get-or-create; adapt to your own layer).
lock_mcp_email = claims.get("email") or claims.get("preferred_username") or f"{claims['sub']}@lockmcp.local"

local_user = get_user_by_email(lock_mcp_email)
if local_user is None:
    local_user = create_user(
        email=lock_mcp_email,
        password=secrets.token_urlsafe(32),  # placeholder — never used to log in locally
        first_name=claims.get("given_name", "LockMCP"),
        last_name=claims.get("family_name", "User"),
        role="AGENT",  # or whatever your default role should be
    )

# Use local_user.id (not claims["sub"]) anywhere your API needs a real local user id —
# e.g. the acting-user foreign key on a new record.
request.state.user = {"id": local_user.id, "email": local_user.email, "lock_mcp_user_id": claims["sub"]}
java
// Run this right after JWT verification succeeds (see LockMcpTokenVerifier above),
// using whatever persistence layer your app has (this example uses JPA/Hibernate).
public class LockMcpUserResolver {
    private final EntityManager entityManager;

    public LockMcpUserResolver(EntityManager entityManager) {
        this.entityManager = entityManager;
    }

    /** Find-or-create the local user row this LockMCP identity maps to. */
    @Transactional
    public LocalUser resolve(DecodedJWT verified) {
        String email = verified.getClaim("email").asString();
        if (email == null) email = verified.getClaim("preferred_username").asString();
        if (email == null) email = verified.getSubject() + "@lockmcp.local";

        LocalUser existing = entityManager
            .createQuery("SELECT u FROM LocalUser u WHERE u.email = :email", LocalUser.class)
            .setParameter("email", email)
            .getResultStream()
            .findFirst()
            .orElse(null);
        if (existing != null) return existing;

        LocalUser created = new LocalUser();
        created.setEmail(email);
        created.setPassword(java.util.UUID.randomUUID().toString()); // placeholder — never used to log in locally
        created.setFirstName(java.util.Optional.ofNullable(verified.getClaim("given_name").asString()).orElse("LockMCP"));
        created.setLastName(java.util.Optional.ofNullable(verified.getClaim("family_name").asString()).orElse("User"));
        created.setRole("AGENT"); // or whatever your default role should be
        entityManager.persist(created);

        // Use created.getId() (not verified.getSubject()) anywhere your API needs a
        // real local user id — e.g. the acting-user foreign key on a new record.
        return created;
    }
}

The created row has no usable password — it exists only to satisfy your own foreign keys and should never be treated as a real, login-capable local account.

Remember to actually deploy these changes. A local edit to a Render/Vercel/etc.-hosted API has no effect on what LockMCP calls until it's pushed and deployed — retrying the same tool call against an undeployed fix looks identical to the fix not working.

Contrast with the other models

Identity BrokeringToken ExchangeTenant Delegated
Who authenticates the userLockMCPYour IdPYour app's login page
You host auth infrastructureNoYou already doYes — login + endpoints
Browser OAuth clients supportedYesNoYes
Your backend called to validate authNoNoYes — validate endpoint
Your API called if its tools reach itYes — same as any modelYes — same as any modelYes — same as any model
Setup effortLowestTwo fieldsHighest

Identity Brokering and Token Exchange share the same runtime verification path — the difference is where sign-in happens, not how tool calls are authenticated.

Where to go next