
# 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.

```json title="config/policies.json"
{
  "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` <code className="text-green-600">&lt;string&gt;</code> - The name of your policy instance. This is used as a reference in your routes.
- `policyType` <code className="text-green-600">&lt;string&gt;</code> - The identifier of the policy. This is used by the Zuplo UI. Value should be `internal-only-inbound`.
- `handler.export` <code className="text-green-600">&lt;string&gt;</code> - The name of the exported type. Value should be `InternalOnlyInboundPolicy`.
- `handler.module` <code className="text-green-600">&lt;string&gt;</code> - The module containing the policy. Value should be `$import(@zuplo/runtime)`.
- `handler.options` <code className="text-green-600">&lt;object&gt;</code> - The options for this policy. [See Policy Options](#policy-options) below.

### Policy Options

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

- `status` <code className="text-green-600">&lt;integer&gt;</code> - 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` <code className="text-green-600">&lt;string&gt;</code> - 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` <code className="text-green-600">&lt;string&gt;</code> - 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 origin                                                                 | Result   |
| ------------------------------------------------------------------------------ | -------- |
| Any HTTP client, with or without credentials                                   | Rejected |
| `context.invokeRoute` from a policy, handler, or custom module on this gateway | Passes   |
| The MCP Server handler calling the route as a tool                             | Passes   |
| An AI Gateway application calling another application on the same gateway      | Passes   |

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:

| `options`                                          | Response 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`:

```json
{
  "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`](/docs/articles/openapi#x-internal) to keep it out of your
developer portal as well:

```json
"/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](/docs/programmable-api/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:

```json
"/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:

```json
{
  "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:

```json
{ "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:

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

A direct call from outside the gateway is rejected:

```bash
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](/articles/policies)
