CodeGym /Courses /ChatGPT Apps /Access control and least privilege: scopes, segmentation,...

Access control and least privilege: scopes, segmentation, per-tool permissions

ChatGPT Apps
Level 15 , Lesson 0
Available

1. Why think about permissions in a ChatGPT‑App at all (and what’s the special risk)

In a “typical” web application there are just a couple of layers between the user and your data: frontend, API, DB. In a ChatGPT‑App there’s one more active participant between the user and your API — the LLM. And it’s not just a “text filter”, but an entity that:

  • independently chooses which tools to call and with what arguments;
  • can be tricked by a prompt injection in the data;
  • can “mix up” tools or invent arguments you didn’t expect.

If you grant the LLM too much power, you get the classic Confused Deputy problem: the model faithfully executes what it believes the user or the text in documents is asking for, but ends up calling delete_all_orders instead of get_last_order.

Therefore, our goals are:

  1. Minimize the rights of auth_tokens (what data and actions are available at all).
  2. Restrict which tools are available to the model in a specific scenario.
  3. Add human control where consequences are especially critical.

And we must do all this without paranoia and blanket bans, otherwise the App becomes useless. The balance between convenience and security is our main quest in this module.

2. Access model in the ecosystem: who touches what

To avoid confusion, let’s look at the system as a whole. We have several layers, each with its own area of responsibility and its own permissions.

flowchart TD
  U[User in ChatGPT] --> C[ChatGPT UI + LLM]
  C --> A["Your App (visual plan + widget)"]
  A --> G[MCP Gateway / API Edge]
  G --> S[MCP servers and microservices]
  S --> D[Databases, queues, external APIs]

Roles at a glance:

  • ChatGPT UI and LLM: managed by OpenAI. You provide instructions (system prompt, tool descriptions), but you do not control the platform’s internal tokens and permissions.
  • Your App (plan, tools, widget): you decide which tools are available, how they are described, what UX confirmations are needed, and what data the widget may display.
  • MCP Gateway / API Edge: this is where token verification happens, mapping of userId, tenantId, the list of scopes, and routing to the required service.
  • MCP servers and microservices: execute tools, make DB and external API calls. This is where checks must be as strict as possible: scopes, tenant isolation, input validation.
  • Storage and external APIs: the last line of defense (DB-level restrictions, permissions of external service accounts).

The key idea: the LLM is not a source of access rights. Everything that reaches the MCP server is treated as “a user request formulated by the model.” Deciding whether the operation is actually permitted is the responsibility of your backend code, not the prompt.

3. AuthN vs AuthZ: what you already have and what we add

In the authentication module you already did:

  • AuthN (Authentication) — you determined who the user is. Via OAuth 2.1/PKCE, ChatGPT obtained a token from the IdP, which was then attached to MCP calls. It contained sub, user_id or similar, sometimes tenant_id.
  • Basic AuthZ — you might already have separated user/admin roles and at least checked “is this a user” or “is this an admin.”

Now we add complexity:

  • every auth_token must carry a set of scopes — string permissions of the form resource:action, e.g., catalog:read, orders:write, payments:create;
  • your MCP server must check these scopes for every action, not just “once at the entry point”;
  • different tools and even different operations within a single tool may require different scopes.

In OAuth 2.1 terms, ChatGPT is a “public client”, the MCP is a “resource server”, and your OAuth server knows which scopes are supported and what they mean. MCP resource metadata typically declares scopes_supported so that ChatGPT can ask the user for exactly the required permissions.

4. Designing scopes for GiftGenius

Let’s take our training project GiftGenius and see what data domains and actions it has. The functionality looks something like:

  • browsing the catalog and gift cards;
  • recommendations based on history;
  • order creation;
  • checkout/payment initiation;
  • admin editing of the catalog.

Instead of creating one all-powerful giftgenius:full_access, it’s better to break this down into reasonable scopes.

Naming convention: resource:action

The resource:action strategy works well, where:

  • resource describes the domain: catalog, recommendations, orders, payments, admin.
  • action describes the type of action: read, write, sometimes more specific: create, delete, manage.

Example for GiftGenius:

Scope What it allows
catalog:read
Read the public gift catalog
recommendations:read
Read the user’s recommendation history
orders:write
Create new orders
orders:read
Read the user’s order history
payments:create
Initiate a payment/checkout
catalog:admin
Edit the catalog (admin UI/support only)

A regular GiftGenius user will require something like (listed space-separated): catalog:read recommendations:read orders:write orders:read payments:create. For an administrator add catalog:admin.

Important: do not create a universal *:* or admin:all. The more granular, the easier it is to revoke a specific permission later without breaking the entire app.

Types of scopes: read vs write vs critical

It helps to mentally tag scopes with categories:

  • safe (read): do not change state, at most reveal data;
  • mutating (write): create/modify entities, increment counters, but don’t touch money and don’t delete everything;
  • critical: payments, account deletion, mass data deletion.

For critical rights, apply stronger control:

  • grant them to the minimum number of users;
  • ask the user for explicit consent in the ChatGPT UI when issuing the token;
  • on the MCP side, require additional confirmation (e.g., a one-time PIN — more advanced scenarios).

Scopes in code: RequestContext and requireScope

At the MCP level it’s convenient to introduce a single context type:

// mcp/context.ts
export interface RequestContext {
  userId: string;        // who
  tenantId: string;      // within which organization
  scopes: string[];      // which permissions the token has
}

// Simple helper to check permissions
export function requireScope(
  ctx: RequestContext,
  needed: string
) {
  if (!ctx.scopes.includes(needed)) {
    throw new Error(`Missing scope: ${needed}`);
  }
}

It is assumed that you form RequestContext in the MCP Gateway after validating the token: decode the JWT, verify the signature/expiration, extract sub, tenant, scope — and then attach this context to all tool calls.

Then in a tool handler:

// mcp/tools/createOrder.ts
import { requireScope, RequestContext } from "../context";

export async function createOrder(
  input: CreateOrderInput,
  ctx: RequestContext
) {
  requireScope(ctx, "orders:write");
  // then — order creation logic
}

Now, even if the model suddenly calls createOrder where your UX didn’t expect it, without orders:write the tool simply won’t execute.

securitySchemes at the tool level

The MCP specification allows each tool to declare which authorization schemes and scopes it requires. In the official examples, securitySchemes are attached directly to the tool description.

A hypothetical example:

// mcp/server.ts
server.registerTool(
  "createOrder",
  {
    title: "Create order",
    description: "Creates a new order for current user",
    inputSchema: {/*...*/},
    securitySchemes: [
      { type: "oauth2", scopes: ["orders:write"] }
    ]
  },
  async ({ input }, ctx: RequestContext) => {
    requireScope(ctx, "orders:write");
    // ...
  }
);

There are two layers of protection here:

  • declarative: ChatGPT knows that this tool needs orders:write, and if permissions are missing it will initiate the auth flow (or inform the user);
  • imperative: your code checks everything again before the real action.

If a token is present but lacks scopes, the server should return an error with WWW-Authenticate: Bearer error="insufficient_scope", scope="orders:write" — and ChatGPT will be able to request a scope upgrade from the user (step‑up authorization).

Insight

The official examples use securitySchemes. It was not approved in the official specification in the form written in the ChatGPT Apps SDK examples. Therefore, it needs to be marked as an extension of the official protocol — wrapped in _meta. A working version of the example above:

// mcp/server.ts
server.registerTool(
  "createOrder",
  {
    title: "Create order",
    description: "Creates a new order for current user",
    inputSchema: {/*...*/},
    _meta: {										// like this
      securitySchemes: [
        { type: "oauth2", scopes: ["orders:write"] }
      ]          
    }
  },
  async ({ input }, ctx: RequestContext) => {
    requireScope(ctx, "orders:write");
    // ...
  }
);

5. Per‑tool permissions and “dangerous” tools

Scopes answer the question “what can this auth_token do in principle.” But the token also contains the list of tools the model can use. These also need careful design.

Tool classification

Roughly divide tools into:

  • informational (informational / read‑only): read data, build reports, compute without side effects;
  • consequential (consequential): change state, charge money, delete things.

The ChatGPT Apps docs explicitly recommend marking read‑only tools as safe, and for dangerous ones — describing consequences and enabling additional UX confirmations.

This can be done:

  • via annotations on the tool (fields like readOnlyHint, destructiveHint);
  • via textual description: “This tool irreversibly deletes orders”;
  • via a separate confirmation_required flag that your App plan uses to insert a confirmation step into the dialogue.

UX confirmations for critical actions

For example, GiftGenius has a chargeCustomer tool (initiates a charge). You obviously don’t want the model to call it without the user’s consent.

What this might look like at the App plan level:

// app/plan/tools.ts (pseudocode)
export const tools = [
  {
    name: "giftgenius.list_catalog",
    description: "Show the gift catalog",
    annotations: { readOnlyHint: true }
  },
  {
    name: "giftgenius.create_order",
    description: "Create an order without payment",
    annotations: { consequential: true }
  },
  {
    name: "giftgenius.charge_customer",
    description: "Charge for the order",
    annotations: {
      consequential: true,
      destructiveHint: true,
      confirmationRequired: {
        title: "Charge the card?",
        message: "A payment will be processed for order N."
      }
    }
  }
];

The exact field names depend on the SDK version, but the idea aligns with the recommendations: mark read‑only tools as safe, and dangerous ones as requiring explicit confirmation and a clear explanation in the description.

Then your widget can react: if the model proposes calling charge_customer, you show a modal with a clear wording and only after the user clicks “Confirm” do you actually perform the tool call.

Simplified widget component example:

// widget/components/ConfirmCharge.tsx
export function ConfirmCharge(props: {
  orderId: string;
  onConfirm: () => void;
}) {
  return (
    <div>
      <p>Charge for order {props.orderId}?</p>
      <button onClick={props.onConfirm}>
        Yes, confirm payment
      </button>
    </div>
  );
}

The model initiates the idea “time to pay”, but a human presses the final button. That is the human‑in‑the‑loop security folks love.

Tools for agents/back office only

Another common case: you have tools that only agents (in the sense of the Agents SDK) or internal admin UIs can use, but not a “regular” user ChatGPT App.

For example, rebuildSearchIndex or syncCatalogFromERP. It’s better to:

  • not include them in the general tools list for the regular App;
  • configure them in a separate agent/orchestrator;
  • protect them with separate scopes and, possibly, a separate auth perimeter.

If you simply add them to the list of available App tools, you increase the risk that the model suddenly decides: “Let’s rebuild the index right now — maybe that will help find a gift.”

6. Network segmentation and trust boundaries

Permissions are not only scopes on a token. The second big axis is network and service segmentation.

The ideal picture:

  • you have exactly one public entry point to the backend — MCP Gateway/Edge API;
  • everything that stores PII and money lives in a private network/VPC and is reachable only through that gateway;
  • outbound traffic from the backend is restricted to an allowlist of domains (payments provider, CRM, your microservices).

Diagrammatically:

flowchart LR
  ChatGPT -- HTTPS --> Edge[API Gateway / MCP Endpoint]
  Edge -- private network --> MCP[MCP server]
  MCP -- private --> DB[(DB with PII)]
  MCP -- private --> SVC[Internal microservices]
  MCP -- HTTPS (allow) --> Stripe[Payments API]

A few important rules here:

  1. DB and internal services are not directly exposed to the internet. Direct access to them is only from the private network and only from services that truly need it.
  2. Edge/Gateway performs auth and rate limiting. It’s the component that verifies the token and scopes, throttles overly frequent requests, and writes the primary audit logs.
  3. Egress control. The MCP server should not be able to call arbitrary internet URLs (SSRF attacks, data leaks). It’s better to explicitly restrict the list of external hosts.

In practice, if you deploy the MCP to Vercel, Render, or a Kubernetes cluster, some of these things are not configured manually, but even there you can still separate:

  • distinct projects/clusters for dev/staging/prod;
  • different environment variables and keys for each environment;
  • a separate “edge” service (HTTP wrapper for MCP) and separate private services.

So we already have two axes of defense: token permissions (scopes) and network boundaries. Let’s add one more — multitenancy, where the same App serves multiple organizations.

7. Multi‑tenant / organizational context

So far we’ve been thinking about a single user. But many ChatGPT applications are multitenant: the same App serves dozens of companies. GiftGenius can easily be turned into a B2B service for corporations: each department with its own catalogs, budgets, orders.

What a tenant is and where to get it

A tenant is usually:

  • an organization/company (Acme Corp);
  • a workspace;
  • sometimes a project or an environment.

The key property: one tenant’s data must not be visible to another.

In the auth flow the tenant is usually placed in:

  • a token claim (tenant, org_id);
  • a separate parameter in the authorization request (less reliable than a claim signed by the IdP).

Important: trust only the tenantId from a verified token, not from tool arguments. If the model generates {"tenantId": "acme"}, but the user’s token has tenantId: "globex", this should be treated as an attack attempt.

Tenant in the request context

Add tenantId to our RequestContext (we already did that above) and do not allow it to be overridden by input data.

Basic check:

// mcp/tenant.ts
import { RequestContext } from "./context";

export function enforceTenant<TInput>(
  input: TInput & { tenantId?: string },
  ctx: RequestContext
) {
  if (input.tenantId && input.tenantId !== ctx.tenantId) {
    throw new Error("Tenant mismatch");
  }
  return { ...input, tenantId: ctx.tenantId };
}

Then in a tool:

// mcp/tools/listOrders.ts
export async function listOrders(
  input: { limit?: number; tenantId?: string },
  ctx: RequestContext
) {
  const safe = enforceTenant(input, ctx);
  return db.order.findMany({
    where: { tenantId: safe.tenantId },
    take: safe.limit ?? 20
  });
}

We ignore the tenant from arguments and forcibly inject it from the context. Thus even if the LLM or an attacker tries to “spoof” a foreign tenant, it won’t work.

Tenant isolation at the DB level

Architecturally there are different options:

  • a separate DB per tenant;
  • separate schemas;
  • a single DB with tenant_id in every table and strict filtering.

Whichever option you choose, the golden rule is the same: no DB query should be executed without filtering by tenant_id from the context. This is especially important in RAG/vector search: if you forget to filter by tenant, the model may start searching documents belonging to other organizations.

8. How this ties into our Next.js/Apps SDK application

Let’s bring this all together and see how scopes, tenant, and network boundaries map to our Next.js project on the Apps SDK. Let’s add more concreteness and look at the Next.js and Apps SDK code.

Where scopes and the tenant live in our project

A typical layout for a training project:

  • In the Next.js application (Apps SDK) you have the App/connector config and OAuth callback pages.
  • In the MCP server — the code that accepts HTTP/SSE requests from ChatGPT, verifies the token, and invokes the required tool.

We carry over everything we discussed:

  1. In the OAuth settings of the MCP resource, declare scopes_supported for GiftGenius (catalog:read, orders:write, etc.).
  2. In the Apps SDK config, describe the App with the list of tools and their annotations (read‑only, consequential, confirmation flows).
  3. In the MCP server implement:
    • token parsing and verification;
    • formation of RequestContext { userId, tenantId, scopes };
    • helpers requireScope, enforceTenant, etc.;
    • DB calls always constrained by tenantId from the context.

Example of an “isolated” path to create an order

Let’s trace one end‑to‑end scenario.

  1. The user writes: “Place an order for this set with a $50 budget.”
  2. The model decides it should call giftgenius.create_order with arguments { productId, budget, ... }.
  3. ChatGPT checks whether the App has the create_order tool and which scopes and securitySchemes are declared for it. It understands that orders:write is needed.
  4. If the token already exists and contains orders:write, the request proceeds; if not, ChatGPT initiates OAuth authorization requesting the required scope.
  5. The MCP Gateway receives the request, verifies the token, forms a RequestContext with userId=123, tenantId="acme", scopes=["catalog:read","orders:write",...].
  6. Inside the MCP createOrder:
    • calls requireScope(ctx, "orders:write");
    • locks the tenant via enforceTenant;
    • creates the order only within tenantId="acme".
  7. If the order requires immediate payment, the model or the backend then initiates charge_customer, where:
    • the tool in the plan is marked as confirmationRequired;
    • the widget renders ConfirmCharge and asks the user to explicitly confirm the charge.

This gives us defense in depth: overly broad prompts, prompt injections, or even UX bugs do not lead to uncontrolled actions, because at the bottom of the pyramid there are strict scope checks, tenant enforcement, and manual confirmations for critical actions.

9. Typical mistakes in designing permissions and segmentation

Error #1: One fat scope like app:full_access.
This approach is fine for a demo but dangerous in production. Lose one token — lose everything. You cannot revoke or forbid a single operation without breaking the rest. Split permissions by domains and operation types (read/write/critical).

Error #2: Checking permissions only “at the gate” and not inside tools.
Sometimes people do this: “since ChatGPT obtained a token, it can do everything.” Then the createOrder tool is simply called even if that specific token doesn’t have orders:write. The correct approach is to check scopes in every tool (or at least via centralized middleware for all mutating operations).

Error #3: Not labeling dangerous tools and not requiring confirmation.
If a tool charges money, deletes data, or changes access, it shouldn’t look to the model the same as listCatalog. The absence of explicit annotations and UX confirmations increases the chance that the model will call it “just because it seems logical.” At a minimum, separate read‑only and destructive tools and clearly mark the latter.

Error #4: Trusting tenantId from the tool’s arguments.
A very common anti‑pattern: a tool getOrders({ tenantId }) where tenantId comes from the model. If you use it as‑is, a user from tenantA can access tenantB’s data by simply specifying a different identifier. The tenant must come from a verified token and be enforced on all DB and external service requests; user-provided values are either ignored or validated for a match.

Error #5: MCP/DB directly accessible from the internet.
Sometimes in simple prototypes the MCP server and DB are exposed to the internet on bare HTTP/5432. In prod you cannot do that: all access must go through a single secured gateway/proxy, and the DB must live in a private network. Otherwise any vulnerable endpoint or leaky webhook is a direct path to your data.

Error #6: Using the same scopes/secrets in dev and prod.
A favorite way to accidentally delete prod data during a local dev demo. Each environment must have its own keys, scopes, and DBs. Even if someone obtains a dev token, they won’t be able to harm prod data.

Error #7: Reluctance to “deny the model.”
Sometimes developers worry: “If I frequently return insufficient_scope or forbidden errors, the model will work worse.” In practice this is normal and expected: the model learns which actions are available and which require additional rights or confirmation. It’s worse if it “successfully” does what it shouldn’t — for example, charges the user twice.

1
Task
ChatGPT Apps, level 15, lesson 0
Locked
Minimal requireScope for a single MCP tool
Minimal requireScope for a single MCP tool
1
Task
ChatGPT Apps, level 15, lesson 0
Locked
Per-tool permissions: securitySchemes + readOnly/destructive annotations
Per-tool permissions: securitySchemes + readOnly/destructive annotations
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION