Authentication
The agent-server does not own users. Every /agent-server/api/* endpoint (except two explicit exceptions) inherits
authentication from Pimcore itself: the browser sends the user's Pimcore session cookie, the agent-server validates
it against the PHP backend, and MCP tool calls run with the user's real permissions. Admin endpoints use a separate bearer token.
The three auth flows
| Flow | Used by | Credential | Endpoint reached |
|---|---|---|---|
| User session cookie | Browser → agent-server; agent-server → auth-validator only | Pimcore PHPSESSID cookie | /agent-server/api/* (excluding admin), GET /pimcore-studio/api/user/current-user-information (auth-validator call only) |
| MCP access token (per chat session) | Agent-server → all server-to-server PHP calls for a chat session | Authorization: Bearer pmcp_… | /pimcore-studio/api/bundle/agent/* (bundle API firewall), /pimcore-mcp/agent/* (MCP firewall) |
| Admin bearer token | Agent-server → PHP config export; operator → admin endpoints | AGENT_SERVER_ADMIN_TOKEN | GET /pimcore-studio/api/bundle/agent/configurations/export, /agent-server/api/admin/* |
Each flow is detailed below. See MCP Integration → Authentication forwarding for the full token lifecycle (issue / refresh / re-mint / revoke / GC).
User session cookie
Flow
- Browser includes the Pimcore session cookie on every request.
- Nginx proxies to the agent-server, forwarding the cookie.
- Auth middleware calls
GET /pimcore-studio/api/user/current-user-informationwith the cookie. - PHP resolves the session to a Pimcore user and returns identity (ID, username, admin flag, permissions).
- The identity is cached so repeat requests don't re-hit PHP.
Identity cache
The validated identity is cached in-memory keyed by sha256(cookie). TTL defaults to 60 seconds
(AGENT_SERVER_AUTH_CACHE_TTL). After expiry the next request re-validates against PHP.
This is the validation cache only. Conversation data lives in Session Storage.
Bearer scope and the bundle-API firewall
The browser cookie is used only to authenticate the agent-server's own /agent-server/api/* endpoints
(via the auth-validator call GET /pimcore-studio/api/user/current-user-information). All subsequent
server-to-server PHP calls for a chat session use the chat-scoped bearer (Authorization: Bearer pmcp_…):
- Bundle API (
/pimcore-studio/api/bundle/agent/*) — protected by a dedicatedpimcore_agent_bundle_apifirewall whose authenticator chain isSessionBridge → McpAccessTokenAuthenticator → PAT. The firewall definition is exposed as%pimcore_agent.bundle_api_firewall_settings%; the consuming project wires it insecurity.yaml(see Installation → Configure Security). - Internal MCP servers (
/pimcore-mcp/agent/*) — protected by thepimcore_mcpfirewall from Studio Backend.
In both cases McpAccessTokenAuthenticator resolves the bearer to the chat initiator's Pimcore User and
Pimcore's standard permission system applies. An agent has exactly the permissions of the user who started
the chat — no privilege escalation, no service-account fallback.
External MCP servers (mcpServers) receive neither the cookie nor the MCP access token; they use whatever auth
their YAML configures. For the issue/refresh/re-mint lifecycle of the bearer see
MCP Integration → Token lifecycle.
Exceptions
These endpoints do not require a cookie:
GET /agent-server/api/health— health check.GET /agent-server/api/docs— Swagger UI (only whenNODE_ENV≠production).
Per-provider LLM authentication
Each inference provider in pimcore_agent.inference.providers carries its own auth_mode and token. The
agent-server applies provider auth at session-creation time — after resolving which provider an agent uses —
before the SDK session is started:
auth_mode | Effect |
|---|---|
byok | Builds an SDK provider block: { type, baseUrl, apiKey }. The token value (after ${VAR} interpolation) becomes the apiKey. |
github | Sets the session-level gitHubToken. No provider block is added. |
A session's provider credentials are determined once at creation and held for the lifetime of the SDK session. If the agent changes mid-conversation (agent switch) the SDK session is recreated with the new agent's provider resolved from scratch.
Mixing byok and github agents in the same deployment is supported — each agent's session uses its own provider's credentials independently. The single caveat is that only one GitHub identity (GitHub PAT) is used across all github-mode providers in a deployment; multiple distinct GitHub accounts per provider are not yet supported.
For the full schema and examples of provider configuration, see Inference Providers.
Admin bearer token
Admin endpoints are machine-to-machine: the agent-server fetches the config export endpoint in PHP, and an operator may trigger a reload or list models.
AGENT_SERVER_ADMIN_TOKEN=<strong-random-value>
If AGENT_SERVER_ADMIN_TOKEN is unset, admin endpoints return 403. In production use a strong random value.
Endpoints
| Endpoint | Used by |
|---|---|
GET /pimcore-studio/api/bundle/agent/configurations/export | The agent-server, on startup and on every reload. Verified with hash_equals; bypasses the user-session firewall via a PUBLIC_ACCESS rule in security.yaml. |
POST /agent-server/api/admin/reload-agents | Re-fetches the config export, clears sessions, returns model-validation warnings. |
GET /agent-server/api/admin/models | Lists available LLM models, cross-references them against agent configs. |
Example:
curl -X POST http://localhost/agent-server/api/admin/reload-agents \
-H "Authorization: Bearer $AGENT_SERVER_ADMIN_TOKEN"
Mercure publisher JWT
Live cross-client chat sync publishes each turn event to Studio's per-user Mercure topic. This is a fourth, narrow credential — used only to publish to the hub, never to authenticate a user:
| Property | Value |
|---|---|
| Credential | Publisher JWT (HS256), signed with the shared MERCURE_JWT_KEY |
| Signed by | The agent-server, in-process (mercure-publisher, node:crypto — no JWT dependency) |
| Publish scope | URI Template studio-backend-default/user/{id} (least privilege; the hub rejects a …/user/* glob) |
| Target | A private update to studio-backend-default/user/{userId}, POSTed to MERCURE_SERVER_URL |
The agent-server already knows the chat initiator's Pimcore userId (it owns the session), so it targets the
correct user topic without any lookup. Because the update is private, the hub delivers it only to subscribers
whose cookie JWT (minted by Studio's ClientTokenService on app load) authorises that exact topic — so a user's
events reach only that user's own tabs/devices. Cross-tenant isolation is enforced by the hub, not by an
ownership check in agent-server code.
Security notes:
- The shared
MERCURE_JWT_KEYlives in the agent-server env at the same trust level as PHP (PHP signs the same hub with the same secret). It is never shipped to the browser. - Publish scope is limited to the user-topic namespace, never the bare
*(PHP's server token uses the broader scope; the agent-server deliberately stays narrower). - If
MERCURE_JWT_KEYis unset/blank the publisher is a no-op (a startup warning is logged) — chat still works without live sync; PHP remains the record. See Real-time Sync and Configuration → Environment Variables.
CSRF protection
Every state-changing request (POST / PUT / DELETE) to /agent-server/api/* must include X-Requested-With: XMLHttpRequest.
This triggers the CORS preflight for cross-origin requests and blocks silent cross-site form submissions.
The frontend sets the header automatically in both the streaming fetch client (agent-stream.service.ts) and the
RTK Query slice (agent-api-slice.ts). If you add a new endpoint or call from custom code, include the header:
fetch('/agent-server/api/…', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
credentials: 'include',
body: JSON.stringify(payload)
})
Rate limiting
Per-user limits via @fastify/rate-limit, keyed by authenticated user ID (falling back to IP):
| Endpoint group | Limit |
|---|---|
Chat (POST /chat, POST /chat/:id) | 20 / min |
Sessions (GET /sessions) | 30 / min |
Admin (POST /admin/*) | 5 / min |
Over the limit → HTTP 429.
Other security measures
- Credential isolation — user session cookies are not forwarded beyond the auth-validator call. All
server-to-server PHP calls (bundle API and internal MCP servers) use the chat-scoped
pmcp_…bearer; third-party servers undermcpServersreceive only what their YAML defines. The chat session id is bound to the bearer server-side and recovered from the validated token, not from any client-supplied header. - Error sanitisation — internal error details (stack traces, SDK errors, upstream URLs) never reach the client.
SSE
errorevents carry generic messages; full details are server-side logs. - Message length cap — chat messages are capped at 10,000 characters to prevent token-cost abuse and storage exhaustion.
- Request IDs — every response returns
X-Request-Id. Nginx-supplied IDs are preserved; otherwise a UUID is generated.
Audit log
Security events are emitted as structured JSON with "level": "audit":
| Event | When |
|---|---|
auth_no_cookie | Non-exception endpoint hit without a cookie. |
auth_failed | Cookie validation against Pimcore returned no user. |
session_access_denied | User tried to access another user's session. |
admin_auth_missing / admin_auth_failed | Admin endpoint hit without / with wrong token. |
admin_reload_agents / admin_list_models | Successful admin actions. |
The tool-security hook adds its own audit records — see Tool Security.
