Enforce a token at your API
You build a small notes API that trusts PolicyArc to decide each call. It sends every token it receives to /introspect with what the call needs, and serves the call only when the answer is active. At the end the API serves a read, refuses a write, and drops a token you revoked.
Before you start
POLICYARC_URLandPOLICYARC_ADMIN_KEYfor your environment. Register a client shows how to set them.curl,jqand Node.js 18 or later.
Build and run the API
Total time: 4 min
Add a resource type for the notes
1 minPolicy grants scopes on resource types. Add the type
urn:example:noteswith a read and a write scope.curl -fsS -X PATCH "$POLICYARC_URL/admin/api/policy-data/by-key?dataKey=pbac.operator.controls.resource_registry&path=resource.types.urn:example:notes" \
-H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" \
-H 'Content-Type: application/json' \
-d '{"scopes": ["notes:read", "notes:write"]}'You should see. The PATCH returns the updated resource registry row.
Register the API and the app
1 minThe API is a client too. It needs the
introspectionscope to call/introspect. The app that calls the API getsnotes:readand nothing more.register() {
curl -fsS -X POST "$POLICYARC_URL/register" -H 'Content-Type: application/json' \
-d "{\"client_name\": \"$1\", \"redirect_uris\": [\"https://$1.example/cb\"]}"
}
entitle() {
curl -fsS -X PATCH "$POLICYARC_URL/admin/api/policy-data/by-key?dataKey=pbac.operator.controls.client_entitlements&path=client_id.$1" \
-H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" -H 'Content-Type: application/json' -d "$2"
}
api=$(register notes-api)
export RS_CLIENT_ID=$(jq -er .client_id <<<"$api") RS_CLIENT_SECRET=$(jq -er .client_secret <<<"$api")
app=$(register notes-app)
export APP_CLIENT_ID=$(jq -er .client_id <<<"$app") APP_CLIENT_SECRET=$(jq -er .client_secret <<<"$app")
entitle "$RS_CLIENT_ID" '{"grant_types": ["client_credentials"], "granted_resources": [{"type": "urn:as:introspect", "scopes": ["introspection"]}]}'
entitle "$APP_CLIENT_ID" '{"grant_types": ["client_credentials"], "granted_resources": [{"type": "urn:example:notes", "scopes": ["notes:read"]}]}'You should see. Both registrations print a client_id, and both PATCH calls return the entitlement row.
Write the API
1 minThe API maps each method to the scope it needs. It sends the token and that need to
/introspect. An inactive answer withinsufficient_scopebecomes HTTP 403. Any other inactive answer becomes HTTP 401.cat > notes-api.mjs <<'EOF'
import http from 'node:http';
const {POLICYARC_URL, RS_CLIENT_ID, RS_CLIENT_SECRET, PORT = '4005'} = process.env;
const auth = `Basic ${Buffer.from(`${RS_CLIENT_ID}:${RS_CLIENT_SECRET}`).toString('base64')}`;
const NEEDS = {GET: 'notes:read', POST: 'notes:write'};
async function introspect(token, scope) {
const context = JSON.stringify({resource_type: 'urn:example:notes', scopes: [scope]});
const res = await fetch(`${POLICYARC_URL}/introspect`, {
method: 'POST',
headers: {Authorization: auth, 'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({token, context}),
});
if (!res.ok) throw new Error(`introspect returned ${res.status}`);
return res.json();
}
http.createServer(async (req, res) => {
const token = /^Bearer (.+)$/.exec(req.headers.authorization || '')?.[1];
const scope = NEEDS[req.method];
if (!token || !scope) return res.writeHead(token ? 405 : 401).end();
try {
const answer = await introspect(token, scope);
if (!answer.active) {
return res.writeHead(answer.inactive_reason === 'insufficient_scope' ? 403 : 401).end(JSON.stringify(answer));
}
res.writeHead(200, {'Content-Type': 'application/json'}).end(JSON.stringify({notes: ['hello'], client: answer.client_id}));
} catch (err) {
res.writeHead(503).end(String(err.message));
}
}).listen(Number(PORT), () => console.log(`notes-api on ${PORT}`));
EOFYou should see. notes-api.mjs exists in your working directory.
Start the API
1 minStart it in the background and check that a call with no token is refused. The API listens on port 4005 unless you set
NOTES_PORT. If an earlier run left an API running from this directory, the block stops it first.if [ -f notes-api.pid ]; then
old=$(cat notes-api.pid); kill "$old" 2>/dev/null || true
while kill -0 "$old" 2>/dev/null; do sleep 0.1; done
fi
export NOTES_PORT=${NOTES_PORT:-4005}
export NOTES_URL=http://localhost:$NOTES_PORT
PORT=$NOTES_PORT node notes-api.mjs > notes-api.log 2>&1 &
echo $! > notes-api.pid
for _ in $(seq 50); do grep -q 'notes-api on' notes-api.log && break; kill -0 "$(cat notes-api.pid)" 2>/dev/null || break; sleep 0.2; done
grep -q 'notes-api on' notes-api.log || { cat notes-api.log; echo "Expected the API to start on port $NOTES_PORT" >&2; false; }
code=$(curl -s -o /dev/null -w '%{http_code}' "$NOTES_URL/notes")
echo "$code"
[ "$code" = 401 ] || { echo 'Expected HTTP 401 with no token' >&2; false; }You should see. curl to the API with no token prints 401.
Call it with a token
Total time: 3 min
Get a token for the app
1 minThe policy engine loads new entitlements within a few seconds, so the block waits first.
sleep 5
export APP_TOKEN=$(curl -fsS -u "$APP_CLIENT_ID:$APP_CLIENT_SECRET" "$POLICYARC_URL/token" \
-d grant_type=client_credentials -d scope=notes:read -d resource_types=urn:example:notes | jq -er .access_token)You should see. An access token is stored in APP_TOKEN.
Read, then try to write
1 minexpect() {
local want=$1; shift
local resp; resp=$(curl -sS -w '\n%{http_code}' "$@")
echo "$resp"
[ "$(tail -n1 <<<"$resp")" = "$want" ] || { echo "Expected HTTP $want" >&2; false; }
}
expect 200 "$NOTES_URL/notes" -H "Authorization: Bearer $APP_TOKEN"
expect 403 -X POST "$NOTES_URL/notes" -H "Authorization: Bearer $APP_TOKEN"The read is served. The write is refused, because the token's grant does not cover
notes:write. The body the API passed on is{"active":false,"inactive_reason":"insufficient_scope"}.You should see. GET prints 200 with the notes. POST prints 403 with insufficient_scope.
Revoke the token and call again
1 mincurl -fsS -u "$APP_CLIENT_ID:$APP_CLIENT_SECRET" "$POLICYARC_URL/revoke" -d token="$APP_TOKEN"
resp=$(curl -sS -w '\n%{http_code}' "$NOTES_URL/notes" -H "Authorization: Bearer $APP_TOKEN")
echo "$resp"
[ "$(tail -n1 <<<"$resp")" = 401 ] && grep -qF 'invalid_token' <<<"$resp" ||
{ echo 'Expected HTTP 401 with invalid_token' >&2; false; }
kill "$(cat notes-api.pid)"The API asked again, and the answer was
{"active":false,"inactive_reason":"invalid_token"}. The block then stops the API.You should see. GET prints 401 with invalid_token.
What happened
The token carries no claims your API can read. Every call costs one round trip to /introspect, and policy runs again on that call with the context your API sent. That is how the same token was good for a read and refused for a write. It is also why revocation took effect on the next call. Each introspection writes an audit row under the API's client ID.
Check your work
Next steps
Send feedback about this page