Authorization
Deciding what each caller may do
Authorization decides what a caller may do once Parlant knows who they are. A guest may post a message but not create a customer. A customer may read their own sessions but nobody else's. Internal data, such as the agent's reasoning, stays hidden from customers. The defaults fit a customer-facing deployment as they are, and you can replace any single rule when your product needs more.
- The three checks every request passes through
- What each kind of caller may do by default, including persona sessions
- How session ownership and field redaction are enforced
- How to extend the gate and the authorizer for roles and teams
Authentication works out who is calling. Authorization decides what that caller may do. Parlant keeps the two apart on purpose: a token can be perfectly valid while the requested action is still forbidden. That is why a failed credential returns 401 and a forbidden action returns 403.
Every request passes through three checks, in this order:
- The permission gate asks whether this kind of caller may perform this operation at all. A guest may post a message; a guest may not create a customer.
- The rate limiter asks whether the caller is doing it too often.
- The authorizer asks, for operations on a particular session, whether this caller owns that session.
This page explains what the defaults allow, what they hide, and how to replace any one of them.
What each caller may do
Parlant identifies every API operation by name, and the default gate grants each kind of caller a fixed set. The ones that matter day to day:
| Operation | Anonymous | Guest | Customer | Admin |
|---|---|---|---|---|
| Read agent info | yes | yes | yes | yes |
| Create a guest session | yes | own | no | yes |
| Create a customer session | no | no | own only | any customer |
| Create a persona session | no | no | no | yes |
| Read a session, list and read its events | no | own only | own only | yes |
| Post a customer message, with or without attachments | no | own only | own only | yes |
| List sessions | no | no | own only | yes |
| Read the agent's reasoning and raw tool results | no | no | no | yes |
| Customer management, groups, session administration, diagnostics, log streaming | no | no | no | yes |
Guests and customers get exactly the operations a chat UI needs and nothing else. Exposing Parlant to the browser does not expose administration.
Note that "own only" is not the gate's job. The gate says a customer may read sessions in general; the authorizer decides which ones.
Session ownership
The default authorizer enforces ownership on every session and event endpoint. A customer owns the sessions whose customer_id equals the sub claim of their token. A guest owns the sessions created with its guest token; each token carries a private instance id, so two anonymous browsers can never see each other's conversations. Admins own everything.
Ownership also shapes how sessions get created. A customer creates sessions only for themselves. Parlant takes the customer from the token, and a customer_id in the request body that contradicts it is rejected with 403.
Only an admin may create a session on behalf of another customer, which is what a backend does when it starts an outreach conversation. See Sessions for the endpoints.
Personas follow two extra rules. A persona is a test customer that stands in for a real one while you test your agent.
First, a persona cannot be authenticated as. A customer token whose sub names a persona is rejected with 401. So a real user who happens to share an id with a persona can never land in a test customer's session.
Second, opening a session with a persona is its own operation, "create a persona session" in the table above, and only an admin holds it by default.
Because it is separate, you can grant it on its own. A test runner in CI can be allowed to open persona sessions through a custom PermissionGate, without being given the admin API key. The section below shows how to extend the gate.
Note that the development policy allows every operation, so when you run without authentication locally, personas are reachable like any other customer. These two rules are properties of the production policy.
What gets hidden
Being allowed to read an event is separate from seeing everything inside it. Three fields are admin-only by default, because they carry internal data a customer should never see:
- The model's own reasoning, the "thinking" text on status events. A tool's
narrationand Parlant's own progress labels, such as "Running tools", are not reasoning. They are written for the customer, and every caller reads them. - The raw
result.dataon tool events, which is the backend payload and often holds records or PII. - Parlant's internal event metadata.
For guests and customers, Parlant returns the event with just that part changed. A reasoning event arrives with its message set to "Thinking" and no chunks, the same shape as Parlant's own "Thinking" label, so a UI needs no special case for it. A tool result's data arrives as {"redacted": true}, so a UI can tell "hidden from me" apart from "genuinely empty". Internal metadata is left out. Everything else on the event, including a tool call's arguments and its frontend-only metadata, still arrives. The full data is persisted and visible to admins.
The same idea applies to health. /healthz always answers with a top-level status so load balancers can probe it without credentials. The per-component checks, latency figures, and links to diagnostic reports appear only for callers allowed to read health reports, which by default means admins.
Rate limits
The default limiter counts requests per client IP and returns 429 when a caller exceeds the limit for an operation. Admins are exempt.
Behind a load balancer or reverse proxy, every request looks like it comes from the proxy's address. Give Parlant the exact addresses of the proxies you trust. For requests arriving from one of them, it reads the real client address from the forwarded headers instead. Forwarding headers from any other peer are ignored, since anyone can set them.
The addresses go in trusted_proxies on AuthConfig, the same object that holds your credentials in Authentication:
import parlant.sdk as p
auth = p.AuthConfig(
admin_api_key="...",
customer_jwt_secret="...",
guest_token_secret="...",
trusted_proxies=frozenset({"10.0.1.5", "10.0.1.6"}), # only these may set forwarded headers
)
Per-IP limiting is a floor, not a plan. If you need per-customer or per-plan quotas, or limits shared across several server processes, replace the rate limiter as described below.
Change the rules
The policy is a pipeline of four ports. A port is a component with a fixed interface that you can replace on its own. Each one answers a single question about a request. Replace the one that answers your question and keep the rest:
| Question | Port | Method you implement |
|---|---|---|
| Who is calling? | Authenticator | Covered in Authentication |
| May this kind of caller do this? | PermissionGate | check(principal, operation) -> bool |
| May this caller touch this session? | Authorizer | check_session_access(principal, session) -> bool |
| Is this caller calling too often? | RateLimiter | check(request, operation) -> bool |
Custom facts about a caller, such as roles, scopes, or an organization id, travel in Principal.attributes. The default authenticator copies a customer token's claims there. The default gate and authorizer ignore anything they don't know, so your replacements can read what your tokens carry.
Roles. Your support console may need to show an operator the agent's reasoning for a session they are allowed to access. Granting that field permission does not grant access to every session; the ownership check still runs.
Ask your coding agent to extend the default gate, p.DefaultPermissionGate, rather than rewrite it. Its check receives the principal and the operation, one of the named values on p.Operation, and answers whether that kind of caller may perform it:
import parlant.sdk as p
class RoleGate(p.DefaultPermissionGate):
async def check(self, principal: p.Principal, operation: p.Operation) -> bool:
if await super().check(principal, operation):
return True # everything the default allows
roles = principal.attributes.get("roles", []) # copied from the token's claims
if "support" in roles:
return operation in {
p.Operation.LIST_SESSIONS,
p.Operation.READ_EVENT_REASONING,
p.Operation.READ_TOOL_RESULT_DATA,
}
return False
The same shape lets a CI test runner open persona sessions. Give its token a role of its own and have the gate return True for p.Operation.CREATE_PERSONA_SESSION when that role is present. The runner then tests against personas without holding the admin key.
Tenancy. If members of the same team should be able to read one another's sessions, define that resource-access rule separately from field permissions. The team identifier must come from verified token claims and session metadata set by your backend.
Extend p.OwnershipAuthorizer, the default that enforces ownership, and read the team from the session's metadata, the free-form data your backend attaches to a session when it creates it:
class TeamAuthorizer(p.OwnershipAuthorizer):
async def check_session_access(self, principal: p.Principal, session) -> bool:
if await super().check_session_access(principal, session):
return True # the owner still gets in
team = principal.attributes.get("team")
return team is not None and session.metadata.get("team") == team # so does a teammate
Compose your replacements with the standard authenticator and pass the result as the server's auth. p.CompositeAuthorizationPolicy bundles the four ports, and p.TokenAuthenticator is the default authenticator, taking the same credentials as AuthConfig:
policy = p.CompositeAuthorizationPolicy(
authenticator=p.TokenAuthenticator(
admin_api_key="...",
customer_jwt_secret="...",
guest_token_secret="...",
),
permission_gate=RoleGate(), # your gate
authorizer=TeamAuthorizer(), # your authorizer; the rate limiter keeps its default
)
async with p.Server(auth=policy) as server:
...
The ports you leave out keep their defaults. Two rules keep custom policies maintainable: put custom concepts in Principal.attributes rather than adding fields to the principal types, and grant operations by name from p.Operation rather than by URL, so a route change never silently reopens something.
For total control, implement AuthorizationPolicy itself. The API layer depends only on that interface: it resolves the principal, checks permission and rate limit, checks session access, and configures the app. You can define every one of those yourself.