Securing the endpoints
By default AgentPrism is reachable from localhost and nowhere else. That is the
safe state, and an application that maps it and forgets to configure anything is not
exposed. Everything below is about leaving that state on purpose.
The layers
Section titled “The layers”Four independent layers, applied in this order. Use as many as you need.
flowchart TD
accTitle: HTTP authorization layers
accDescr: A request passes authentication, role policy, optional API-key scope, and optional tenant isolation before an endpoint runs.
REQ["Incoming request"] --> META{"path = /api/meta ?"}
META -->|yes| OK["Endpoint runs"]
META -->|no| POL{"Authorization policy set?"}
POL -->|"set, fails"| F403["403 Forbidden"]
POL -->|"unset or passes"| LB{"Remote access off<br/>and caller not loopback?"}
LB -->|yes| F403b["403 Forbidden"]
LB -->|no| HDR{"Authorization header present?"}
HDR -->|no| TOKU{"AuthToken configured?"}
TOKU -->|yes| F401["401 Unauthorized"]
TOKU -->|no| OK
HDR -->|yes| TOK{"Matches the static token?"}
TOK -->|yes| OK
TOK -->|no| KEY{"Valid API key?"}
KEY -->|no| F401
KEY -->|yes| SC{"Endpoint needs a scope<br/>the key lacks?"}
SC -->|yes| F403c["403 Forbidden"]
SC -->|no| OK
1. The loopback restriction
Section titled “1. The loopback restriction”On by default. A request from anywhere but loopback gets 403. It exists to make
accidental exposure impossible, so turning it off should be a deliberate line in a
review:
app.MapAgentPrism("/agentprism", options => options.AllowRemoteAccess = true);2. A bearer token
Section titled “2. A bearer token”A single shared token, compared in constant time:
options.AuthToken = builder.Configuration["AgentPrism:AuthToken"];Good enough for one operator or a private network. It cannot be revoked individually, carries no identity, and gives everyone the same rights.
3. API keys
Section titled “3. API keys”Issued from /api/api-keys, stored hashed, scoped per capability, revocable, and
optionally expiring. Each key belongs to a tenant.
Scopes narrow a role, they never widen it: what a caller may do is the
intersection of its role and its key’s scopes. Scope values use the closed JSON enum;
for example, a key with only RunsWrite cannot
administer agents no matter what role the caller has.
The compatibility reference lists all 17 values and the capability each one grants.
A key also proves which tenant is calling — which is why it outranks any claim or header for tenant resolution. A secret is proof; a header is a claim.
4. An authorization policy
Section titled “4. An authorization policy”The production path. Hand AgentPrism a policy name and it runs inside your own authentication pipeline:
options.RequireAuthorization("AgentPrismAdmin");Three policy names — Reader, Operator, Admin — that you bind to your own claims:
| Role | Can |
|---|---|
| Reader | Read agents, runs, sessions, traces, statistics |
| Operator | Reader, plus start runs, decide approvals, delete sessions |
| Admin | Everything: write definitions, add MCP servers, manage tenants and approval rules, read the audit trail |
AgentPrism stores no users and no roles. If a policy is not registered in your
application, that endpoint group simply falls back to the layers above — so upgrading
never breaks a working deployment. Turn on RequireRolePolicies and a missing policy
becomes a startup error instead of a silent gap.
Two deliberate exemptions
Section titled “Two deliberate exemptions”/api/meta answers without authentication. The console has to learn which
authentication method to present before it can ask for anything. It returns no
sensitive data.
The console shell — its HTML, JavaScript, and CSS — is exempt from the bearer
layer only. A browser cannot attach an Authorization header to a <script src>
request, so a locked shell would mean the user could never reach the screen that asks
for the token. The shell carries no data. The loopback restriction and the
authorization policy still apply to it, and every data endpoint behind it is fully
protected.
The real-time voice WebSocket is not an exemption: a browser cannot set a header on a
handshake either, so the token travels in the Sec-WebSocket-Protocol subprotocol and
is verified in constant time. A query string was rejected — it would be written to
server and proxy logs.
Outbound requests are guarded too
Section titled “Outbound requests are guarded too”AgentPrism reaches the network from three places, and each one accepts an address that
ultimately came from a user. That makes all three an SSRF risk — cloud metadata
endpoints (169.254.169.254) included, which often hand out unauthenticated temporary
credentials.
| Surface | Address comes from |
|---|---|
| Webhook delivery | A subscription’s url |
| MCP server connections | A server definition’s endpoint |
| Model provider calls | A tenant’s provider binding endpoint (BYOK) |
One guard covers all three. Private network targets are refused by default:
{ "AgentPrism": { "Egress": { "AllowPrivateNetworkTargets": false } }}Refused ranges include 10/8, 172.16/12, 192.168/16, 169.254/16, loopback,
CGNAT, and the IPv6 equivalents. An IPv6 address that embeds an IPv4 address is
reduced to that IPv4 address first and judged by the same rules, so
::ffff:169.254.169.254, ::169.254.169.254, 64:ff9b::a9fe:a9fe (NAT64) and
2002:a9fe:a9fe:: (6to4) are all refused as well.
Turn the setting on if your MCP servers really do run inside the private network. The rejection message names the setting, so an operator who hits it knows what to change.
The check lives inside the socket connect callback, so the address that is validated
is the address the socket connects to. Validating a URL and then calling it would
leave a time-of-check/time-of-use gap: HttpClient would resolve the name a second
time, and an attacker can change the answer between the two lookups (DNS rebinding).
Because the guard runs per connection, a name that passed when it was saved is checked
again every time it is used.
Webhook delivery keeps two extra rules of its own: only https is accepted (unless
AllowInsecureHttp is on, which permits loopback only), and redirects are not
followed — a redirect is an escape route into a private network.
Configuration keys are fenced too
Section titled “Configuration keys are fenced too”A stored record never holds a secret value; it holds the name of the configuration
key the value is read from. That alone is not enough, so each name must sit under an
allowed prefix. Without it, a record could name ConnectionStrings:Default as its
“API key” and AgentPrism would send that value to a remote server.
| Record | Field | Default prefix |
|---|---|---|
| Inbound trigger | signingSecretConfigurationName |
AgentPrism:TriggerSecrets: |
| Tenant provider binding | apiKeyConfigurationName |
AgentPrism:ProviderKeys: |
| MCP server | authorizationConfigurationKey, oauthClientSecretConfigurationKey |
AgentPrism:McpSecrets: |
| Webhook subscription | secretConfigurationKey |
AgentPrism:WebhookSecrets: |
Each prefix is configurable through the matching options section, and the rule is enforced twice: where the record is saved, and again where the value is resolved — so a record written before a prefix was configured cannot quietly read outside it.
What is stored in the clear
Section titled “What is stored in the clear”AgentPrism does not encrypt content at rest. Your database holds these in plain form, and you should plan for that before storing regulated data:
| Column | What it holds |
|---|---|
conversation_items.item |
The full conversation history |
run_inputs.messages |
The prompt a run was started with |
run_events.text, run_events.payload |
Streamed output and event detail |
tool_invocations.arguments, .result |
What a tool was called with, and what it returned |
sessions.state, responses.payload |
Session state and provider responses |
attachments.content, agent_files.content |
Uploaded bytes and agent file contents |
Secrets are the exception and are handled separately: a credential value is never written to the database. Only the name of the configuration key is stored, and the value is resolved at call time from your configuration. API keys are stored as a SHA-256 hash, never as a recoverable value.
Protect the rest at the layer below: full-disk or tablespace encryption, a managed database with encryption at rest, and retention policies that delete what you no longer need. Column-level encryption inside AgentPrism is a known gap, not a shipped feature.
A short checklist
Section titled “A short checklist”-
AllowRemoteAccessis on only together with a token, a key, or a policy - Secrets are in user-secrets, the environment, or a secret store — never a file
- Roles are bound to your claims, and
RequireRolePoliciesis on - Quotas are set, so one caller cannot spend the whole model budget
- Retention policies exist for run events and traces
- Encryption at rest is provided by the database or the disk — AgentPrism stores content in the clear
- Skill script execution is left off unless you have read what it does
-
AgentPrism:Egress:AllowPrivateNetworkTargetsis on only if your MCP servers or provider endpoints really are on the internal network -
IToolAuthorizationHandleris implemented for any tool that should not be callable by every caller — see Tools: authorization and timeout
Read next
Section titled “Read next”- Governance — audit trail, quotas, tenancy
- Architecture — how the pieces fit