Check a token at your resource server with introspection
Use this page when your own API receives PolicyArc tokens and you do not put it behind the managed gateway. Your API sends each token to /introspect with the details of the call. Policy runs again for that call, and the answer says whether to serve it.
The managed gateway makes the same call for every resource it fronts. See How the managed gateway works.
Give your API a client that may introspect
Your API authenticates to /introspect as a client. That client needs the introspection or uma_protection scope on the built-in type urn:as:introspect. This registers one and grants it.
reg=$(curl -fsS -X POST "$POLICYARC_URL/register" \
-H 'Content-Type: application/json' \
-d '{"client_name": "orders-api", "redirect_uris": ["https://orders-api.example/cb"], "token_endpoint_auth_method": "client_secret_basic"}')
export RS_CLIENT_ID=$(jq -r .client_id <<<"$reg")
export RS_CLIENT_SECRET=$(jq -r .client_secret <<<"$reg")
curl -fsS -X PATCH "$POLICYARC_URL/admin/api/policy-data/by-key?dataKey=pbac.operator.controls.client_entitlements&path=client_id.$RS_CLIENT_ID" \
-H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" \
-H 'Content-Type: application/json' \
-d '{"grant_types": ["client_credentials"], "granted_resources": [{"type": "urn:as:introspect", "scopes": ["introspection"]}]}'
Introspect with the call's context
$TOKEN is the token your API received. The examples use the orders:read token from Register a managed resource. The block first waits a few seconds for the policy engine to load the grant.
sleep 5
resp=$(curl -fsS -u "$RS_CLIENT_ID:$RS_CLIENT_SECRET" "$POLICYARC_URL/introspect" \
-d "token=$TOKEN" \
--data-urlencode 'context={"resource_type": "urn:example:orders", "scopes": ["orders:read"]}')
echo "$resp"
jq -e '.active == true' <<<"$resp" >/dev/null || { echo 'Expected "active": true' >&2; false; }
context is one form field that holds a JSON object. It describes this call, and policy reads it as input.context.rs_context. These are the keys the gateway sends, and your API can send the same ones.
| Key | Value |
|---|---|
resource_type | The type of the resource being called |
scopes | The scope this call needs, as a list |
resource_id | The id of the record being called |
resource_name | A name for the resource |
tool_name | The MCP tool name, if any |
tool_arguments | The call's arguments, as an object |
What we saw with an orders:read token.
| Context | Response |
|---|---|
| None | "active": true, with scope, client_id, exp and extensions.granted_resources |
"scopes": ["orders:read"] | "active": true. With the redact control from Obligations deployed, extensions.obligations held filter_words. |
"scopes": ["orders:write"] | {"active": false, "inactive_reason": "insufficient_scope"} |
| A token that does not exist | {"active": false, "inactive_reason": "invalid_token"} |
Every answer is HTTP 200. Serve the call only when active is true and the response grants the scope and resource type you expect, as Check the scope and resource describes. Then apply each entry of extensions.obligations you support. invalid_token means the token is expired, revoked or unknown. insufficient_scope means the token is valid and policy refused this call.
Check the scope and resource
Known issue. On 0.65.0 active: true alone does not prove that the token was issued for your resource type. A token issued for another type can come back active when its scope names match the scopes in your context. In our run a token for urn:example:a with scope read, introspected with {"resource_type": "urn:example:b", "scopes": ["read"]}, returned "active": true with "scope": "" and an empty extensions.granted_resources. The same happened for a type that is not registered, and when the context sent no scopes. A token whose scope names did not match got insufficient_scope.
Before you serve the call, also check that scope contains the scope the call needs and that extensions.granted_resources has an entry whose type is your resource type. An active answer with an empty scope grants nothing for the type you asked about. Code samples shows the check.
Authenticate as a bearer
Instead of Basic auth, your API can present its own token with the introspection scope.
INTROSPECT_TOKEN=$(curl -fsS -u "$RS_CLIENT_ID:$RS_CLIENT_SECRET" "$POLICYARC_URL/token" \
-d grant_type=client_credentials -d scope=introspection -d resource_types=urn:as:introspect |
jq -er .access_token)
resp=$(curl -fsS -H "Authorization: Bearer $INTROSPECT_TOKEN" "$POLICYARC_URL/introspect" -d "token=$TOKEN")
echo "$resp"
jq -e '.active == true' <<<"$resp" >/dev/null || { echo 'Expected "active": true' >&2; false; }
A client that is not entitled gets HTTP 401 {"error":"invalid_client","error_description":"Client is not entitled to introspection or uma_protection scope"}.
Single-use tokens
A token issued under the jit_single_use control answers active: true once. The server marks it used before it answers, so of two calls at the same moment, only one gets active: true. We read this in the code and did not run it for this page. Give an agent single-use tokens turns it on.
Each call is audited
Each call writes an /introspect audit row with the caller's client id, the policy input and the decision. With context, the row's policyInput.context.rs_context holds what your API sent.
Send feedback about this page