Security & Validation

Internal Only Policy

The Internal Only policy admits a request only when this gateway made it with context.invokeRoute, and rejects every request that arrived over the network.

Use it on routes that only other routes, handlers, and policies on the same gateway call: REST routes that an MCP server exposes as tools, or the classifier application behind the Smart Router or Prompt Injection Protection policies. Those calls never leave the gateway, so an API key on them protects nothing. A rejected request gets 403 Forbidden by default; set status to 404 to answer as if the route did not exist. The policy makes no network calls and never reads the request body.

Configuration

The configuration shows how to configure the policy in the 'policies.json' document.

JSONCode
{ "name": "my-internal-only-inbound-policy", "policyType": "internal-only-inbound", "handler": { "export": "InternalOnlyInboundPolicy", "module": "$import(@zuplo/runtime)", "options": { "status": 403, "statusText": "Not Found" } } }

Policy Configuration

  • name <string> - The name of your policy instance. This is used as a reference in your routes.
  • policyType <string> - The identifier of the policy. This is used by the Zuplo UI. Value should be internal-only-inbound.
  • handler.export <string> - The name of the exported type. Value should be InternalOnlyInboundPolicy.
  • handler.module <string> - The module containing the policy. Value should be $import(@zuplo/runtime).
  • handler.options <object> - The options for this policy. See Policy Options below.

Policy Options

The options for this policy are specified below. All properties are optional unless specifically marked as required.

  • status <integer> - The HTTP status code returned to a request that did not come from inside the gateway. Any client error status from 400 to 499 is allowed. Use 404 to answer as if the route did not exist, or 401 to answer as an authentication policy does. Defaults to 403.
  • statusText <string> - The status text returned with status, such as Not Found. When it is omitted, the response uses the standard text for the status. Clients don't always receive it: HTTP/2 and HTTP/3 have no status text, and Managed Dedicated and self-hosted gateways always send the standard text.
  • detail <string> - The detail of the problem response returned to a rejected request. Every caller outside the gateway receives it, so don't describe how the route is called. When it is omitted, the response has no detail, and a 404 matches the response for a route that does not exist.

Using the Policy

Add this policy first in the inbound policies of each route that only your gateway calls. With no options it rejects requests from outside the gateway with 403 Forbidden; set status, statusText, and detail to change the response.

How it works

context.invokeRoute runs a route on this gateway without leaving the process, and the runtime marks the request it creates by setting context.parentContext to the calling request's context. Nothing else sets that property. A request that arrives over the network never has one, whatever headers, URL, or body it carries. This policy passes a request when context.parentContext is present and rejects it otherwise.

Request originResult
Any HTTP client, with or without credentialsRejected
context.invokeRoute from a policy, handler, or custom module on this gatewayPasses
The MCP Server handler calling the route as a toolPasses
An AI Gateway application calling another application on the same gatewayPasses

The policy does not authenticate the caller and does not set request.user.

Choose the response

By default, a rejected request gets a 403 Forbidden problem response with no detail, so the caller learns nothing about how your gateway works. The policy writes the reason to the request log at debug level, where you can see it and the caller can't.

Set status to answer with any client error status from 400 to 499, statusText to replace the status text, and detail to add a message:

optionsResponse to a request from outside the gateway
{}403 Forbidden with no detail
{ "status": 404 }404 Not Found with no detail, as for a route that does not exist
{ "status": 401 }401 Unauthorized with no detail
{ "status": 401, "statusText": "Access Denied" }401 Access Denied with no detail
{ "detail": "Access denied." }403 Forbidden with your detail

Every caller outside the gateway receives your detail, so don't use it to explain how the route is called or what protects it.

statusText replaces only the status text; the problem's title stays the standard one, such as Unauthorized. Clients don't always receive it: HTTP/2 and HTTP/3 have no status text, and Managed Dedicated and self-hosted gateways always send the standard text.

Hide REST routes behind an MCP server

The MCP Server handler calls each tool's route with context.invokeRoute, so the tools keep working while direct calls to those REST routes get the same 404 as a route that does not exist. Declare the policy with "status": 404:

JSONCode
{ "name": "internal-only-inbound", "policyType": "internal-only-inbound", "handler": { "export": "InternalOnlyInboundPolicy", "module": "$import(@zuplo/runtime)", "options": { "status": 404 } } }

Put it first in the inbound policies of each REST route that the MCP server exposes, so nothing else runs for a rejected request. Mark the route x-internal to keep it out of your developer portal as well:

JSONCode
"/orders/{orderId}": { "get": { "operationId": "get-order", "x-internal": true, "parameters": [ { "name": "orderId", "in": "path", "required": true, "schema": { "type": "string" } } ], "x-zuplo-route": { "corsPolicy": "none", "handler": { "export": "urlForwardHandler", "module": "$import(@zuplo/runtime)", "options": { "baseUrl": "https://orders.example.com" } }, "policies": { "inbound": ["internal-only-inbound"] } } } }

The policy's 404 is the gateway's standard not-found problem. Two things can still tell a hidden route from one that doesn't exist:

  • Preflight OPTIONS requests skip inbound policies, so a route with a CORS policy still answers them. Set corsPolicy to none on hidden routes, as in the example.
  • If you set runtime.notFoundHandler in your runtime extensions, paths that don't exist answer with your handler's response instead.

The MCP server route lists the operation as a tool and authenticates its own callers:

JSONCode
"/mcp": { "post": { "x-zuplo-route": { "handler": { "export": "mcpServerHandler", "module": "$import(@zuplo/runtime)", "options": { "operations": [{ "file": "./config/routes.oas.json", "id": "get-order" }] } }, "policies": { "inbound": ["api-key-inbound"] } } } }

Protect an AI Gateway application

New AI Gateway projects declare this policy as internal-only-inbound. Select it in an application's inboundPolicyChain in place of ai-gateway-auth-inbound, and put it first so nothing else runs for a rejected request:

JSONCode
{ "inboundPolicyChain": [ { "name": "internal-only-inbound" }, { "name": "ai-gateway-model-filtering-inbound" }, { "name": "ai-gateway-metering-inbound" } ] }

A chain entry's options replace the declared options for that application only:

JSONCode
{ "name": "internal-only-inbound", "options": { "status": 404 } }

AWS Bedrock Runtime requests get the AWS error envelope instead of a problem response, so the AWS SDKs can parse the error: AccessDeniedException for a 401 or 403, and ResourceNotFoundException for a 404. Its message is your detail, or the status title, such as Forbidden, when you set none. That holds in an application's chain and on the AI Gateway route itself, ahead of the configuration loader.

Metering records these requests against the application with no consumer, and a budget rule keyed on request.user has no value to accumulate for them. Attach per-consumer budgets to the calling application instead.

The Smart Router and Prompt Injection Protection policies call their classifier applications this way.

Call a protected route

Call the route from a policy or handler on the same gateway. No credentials are needed:

TypeScriptCode
const response = await context.invokeRoute("/orders/1234");

A direct call from outside the gateway is rejected:

TerminalCode
curl https://gateway.example.com/orders/1234

With the default options the response is 403 Forbidden with no detail. The request log records why the policy rejected it.

What it does not check

This policy asserts that a request originated inside this gateway deployment, not that the caller is trusted. Any route on the same gateway that forwards requests through context.invokeRoute, including an MCP server that exposes the route as a tool, reaches it. Protect those routes with their own authentication, exactly as you would an internal URL.

Deprecated names

This policy began in the AI Gateway as Ensure Gateway Internal Invocation Only. Its earlier names are deprecated: the AIGatewayInternalOnlyInboundPolicy export, the ai-gateway-internal-only policy type, and the ai-gateway-internal-only-inbound chain-entry name. Configurations that use them keep working unchanged, but new configurations should use InternalOnlyInboundPolicy and internal-only-inbound.

Read more about how policies work

Last modified on