Integrate Agent SSO
This guide shows how to accept authenticated AI agents on any web service. Agents that hold an Alien Agent ID authenticate with RFC 9449 DPoP — each request carries an SSO-issued access token bound to the agent’s own key, plus a fresh per-request proof. No API keys, no shared secrets, no pre-registration.
The whole contract is built from standard primitives: an RFC 9068 at+jwt access token signed by the Alien SSO, and an RFC 9449 DPoP proof signed by the agent. The two are tied together by the RFC 7800 cnf.jkt confirmation claim. A verified request proves that a specific human authorized a specific agent to make this specific request.
How it works
The access token is a bearer-of-key credential, not a plain bearer token: without a fresh DPoP proof signed by the matching agent key, a captured access token is useless. That binding is the entire point of RFC 9449.
Wire format
Each authenticated request carries two headers:
Authorization: DPoP <access_token> ← Alien at+jwt (RS256), signed by the SSO
DPoP: <proof JWT> ← EdDSA proof, signed by the agent's key<access_token> — RFC 9068 at+jwt issued by Alien SSO
A signed JWS (typ=at+jwt, alg=RS256) whose payload carries standard OAuth claims:
{
"iss": "https://sso.alien-api.com",
"sub": "00000003010000000000539c741e0df8",
"aud": ["your-client-id", "https://sso.alien-api.com"],
"exp": 1774535117,
"iat": 1774531517,
"cnf": { "jkt": "wEf6o2ux8sBAUG4oQYhP284gfpZwUJMTxXDPH5XxthY" }
}| Claim | What it attests |
|---|---|
iss | The issuing SSO (https://sso.alien-api.com). |
sub | The human owner’s AlienID address. |
aud | The token audience. The Alien SSO emits aud = [client_id, issuer]. |
exp | Access-token expiry (NumericDate). |
cnf.jkt | RFC 7638 SHA-256 thumbprint of the agent’s Ed25519 public key (RFC 7800 §3.1). |
The signature is verified against the SSO’s JWKS, fetched once and cached.
<proof JWT> — RFC 9449 DPoP proof, minted per request
A JWS signed by the agent’s Ed25519 key. The JOSE header carries the full public JWK; the payload binds the proof to one specific request:
// header
{
"typ": "dpop+jwt",
"alg": "EdDSA",
"jwk": { "kty": "OKP", "crv": "Ed25519", "x": "..." }
}
// payload
{
"htm": "GET", // request method
"htu": "https://api.acme.example/v1/orders", // request URL (no query, no fragment)
"iat": 1774531517, // proof creation time
"jti": "01HXYZ...", // unique per proof (replay defense)
"ath": "<base64url(sha256(access_token))>" // binds proof to this access token
}The proof’s jwk thumbprint equals the access token’s cnf.jkt (RFC 7800 §3.1), which is what binds the SSO’s attestation of the human owner to the agent key that signed this request.
Verify on the service side
The @alien-id/sso-agent-id package ships a complete RFC 9449 verifier for Node.js services.
npm install @alien-id/sso-agent-idZero runtime dependencies — uses Node.js built-ins (crypto, URL, fetch). Requires Node 18+.
fetchAlienJWKS(ssoBaseUrl?)
Fetches the SSO’s JSON Web Key Set, used to verify access-token signatures. Hits ${ssoBaseUrl}/oauth/jwks. Cache the result at startup and refresh periodically (e.g. every few hours, or on an unknown-kid miss).
function fetchAlienJWKS(ssoBaseUrl?: string): Promise<JWKS>| Parameter | Type | Default | Description |
|---|---|---|---|
ssoBaseUrl | string | DEFAULT_SSO_BASE_URL | SSO base URL. Override for self-hosted deployments. |
DEFAULT_SSO_BASE_URL is exported as "https://sso.alien-api.com".
Returns: Promise<JWKS> — { keys: JWK[] }. Throws if the fetch fails or the response has no keys[].
import { fetchAlienJWKS, DEFAULT_SSO_BASE_URL } from "@alien-id/sso-agent-id";
const jwks = await fetchAlienJWKS(); // production SSO
const custom = await fetchAlienJWKS("https://sso.example.com"); // self-hostedverifyDPoPRequest(req, opts)
Verifies an inbound HTTP request that carries a DPoP proof alongside an Alien at+jwt access token. Walks the RFC 9449 §4.3 checklist, the RFC 7800 §3.1 / §6.1 cnf.jkt binding, and the RFC 9068 §4 access-token claim checks. Synchronous — pass it the pre-fetched JWKS.
function verifyDPoPRequest(
req: {
method: string;
url: string;
headers: Record<string, string | string[] | undefined>;
},
opts: VerifyDPoPOptions,
): VerifyDPoPResultreq must be the full request. The verifier compares the proof’s htm against req.method (case-sensitive) and its htu against req.url (query and fragment stripped on both sides). Behind a reverse proxy, reconstruct the external URL the agent actually addressed (honor X-Forwarded-Proto / X-Forwarded-Host only from trusted proxies) or htu comparison will fail.
opts: VerifyDPoPOptions:
| Option | Type | Default | Description |
|---|---|---|---|
jwks | JWKS | — (required) | Pre-fetched SSO JWKS (see fetchAlienJWKS). |
expectedIssuer | string | "https://sso.alien-api.com" | Required access-token iss. Override for staging/self-hosted SSO. |
expectedAudience | string | false | expectedIssuer | Federated audience (see below). String to scope to one client_id/resource; false to skip (test fixtures only). |
proofMaxAgeSec | number | 30 | DPoP proof freshness window, in seconds. The proof’s iat must be within ±this of now. |
clockSkewSec | number | 30 | Clock-skew allowance, in seconds, applied to the access token’s exp. |
jtiStore | DPoPJtiStore | in-memory Map | Replay-protection store for the proof’s jti. Default is single-process, capped at 10,000 entries. |
Options are in seconds, not milliseconds.
Returns VerifyDPoPResult, a discriminated union. On success:
interface VerifyDPoPSuccess {
ok: true;
sub: string; // human owner's AlienID address (at.sub)
jkt: string; // agent's DPoP key thumbprint (at.cnf.jkt)
accessTokenClaims: Record<string, unknown>; // full verified access_token payload
proofClaims: Record<string, unknown>; // full verified DPoP proof payload
}On failure:
interface VerifyDPoPFailure {
ok: false;
code: string; // machine-readable, e.g. "jkt_mismatch"
error: string; // human-readable detail
}sub is always the SSO-attested human owner — there is no separate “deep” or “owner” verification step; the full chain is enforced on every call. jkt uniquely identifies the agent instance.
Federated audience
The Alien SSO mints every access token with aud = [client_id, issuer]. By default the verifier checks that aud contains expectedIssuer — i.e. the token was minted by the Alien SSO at all. This “federated audience” pattern lets one agent identity work against the whole network with no per-service configuration. Override expectedAudience only to narrow acceptance:
// Accept only agents bound to your own OAuth client:
verifyDPoPRequest(req, { jwks, expectedAudience: "YOUR_CLIENT_ID" });
// Skip the audience check entirely (test fixtures only):
verifyDPoPRequest(req, { jwks, expectedAudience: false });Failure codes
Every rejection returns { ok: false, code, error }. code is stable across releases (new values may be added); surface it in a WWW-Authenticate challenge per RFC 9449 §7.1. Roughly in check order:
code | Meaning |
|---|---|
missing_authorization | No (or duplicate) Authorization header. |
invalid_scheme | Authorization is not DPoP <access_token>. |
missing_dpop | No (or duplicate) DPoP proof header. |
malformed_proof | DPoP proof is not a valid JWS. |
bad_proof_typ | Proof typ is not dpop+jwt. |
bad_proof_alg | Proof alg is not EdDSA. |
missing_proof_jwk | Proof header has no jwk. |
bad_proof_jwk | Proof jwk is not a {kty:OKP, crv:Ed25519, x} key. |
private_in_proof_jwk | Proof jwk leaks the private member d. |
proof_sig_error | Proof signature verification threw. |
bad_proof_signature | Proof signature failed verification. |
bad_proof_htm | Proof htm ≠ request method. |
bad_proof_htu | Proof htu ≠ request URL (or not a parseable URL). |
bad_proof_iat | Proof iat is not a NumericDate. |
stale_proof | Proof age exceeds proofMaxAgeSec. |
future_proof | Proof iat is too far in the future. |
missing_proof_jti | Proof has no jti. |
replayed_proof_jti | Proof jti has been seen before. |
malformed_access_token | Access token is not a valid JWS. |
bad_access_token_typ | Access-token typ is not at+jwt. |
bad_access_token_alg | Access-token alg is not RS256. |
unknown_access_token_kid | No JWKS entry matches the access-token kid. |
access_token_sig_error | Access-token signature verification threw. |
bad_access_token_signature | Access-token signature failed verification. |
bad_access_token_iss | Access-token iss ≠ expectedIssuer. |
bad_access_token_aud | Access-token aud does not include expectedAudience. |
expired_access_token | Access token is expired (past exp + clockSkewSec). |
missing_access_token_sub | Access token has no sub. |
missing_cnf_jkt | Access token has no cnf.jkt. |
jkt_mismatch | cnf.jkt ≠ thumbprint of the proof’s jwk. |
bad_proof_ath | Proof ath ≠ sha256(access_token). |
DPoPJtiStore
Replay protection (RFC 9449 §11.1) keys on the proof’s jti. The default in-memory store is single-process. For multi-instance deployments, back it with Redis/Memcached so a captured proof can’t be replayed against a different worker.
interface DPoPJtiStore {
has(jti: string): boolean; // observed inside the freshness window?
add(jti: string, iat: number): void; // record the jti with its proof iat (unix seconds)
}Complete example (node:http)
A self-contained service that fetches the JWKS once, then verifies every request on /api/whoami. This compiles against @alien-id/sso-agent-id@2.1.1.
import http from "node:http";
import {
fetchAlienJWKS,
verifyDPoPRequest,
DEFAULT_SSO_BASE_URL,
} from "@alien-id/sso-agent-id";
import type { DPoPJtiStore } from "@alien-id/sso-agent-id";
// Fetch the SSO JWKS once at startup. Cache it; refresh periodically.
const jwks = await fetchAlienJWKS();
// In-memory jti replay store. Swap for a Redis-backed one across instances.
const seen = new Map<string, number>();
const jtiStore: DPoPJtiStore = {
has: (jti) => seen.has(jti),
add: (jti, iat) => void seen.set(jti, iat),
};
const server = http.createServer((req, res) => {
if (req.url?.split("?")[0] !== "/api/whoami") {
res.writeHead(404).end();
return;
}
// Reconstruct the absolute URL the agent signed into the proof's `htu`.
// Behind a proxy, use the external host (X-Forwarded-* from trusted hops).
const host = req.headers.host ?? "localhost";
const url = `https://${host}${req.url}`;
const result = verifyDPoPRequest(
{ method: req.method ?? "GET", url, headers: req.headers },
{
jwks,
expectedIssuer: DEFAULT_SSO_BASE_URL,
expectedAudience: process.env.SERVICE_AUDIENCE ?? undefined,
jtiStore,
},
);
if (!result.ok) {
res.writeHead(401, {
"WWW-Authenticate": `DPoP error="invalid_token", error_description="${result.code}"`,
"Content-Type": "application/json",
});
res.end(JSON.stringify({ error: result.code, message: result.error }));
return;
}
// Verified: result.sub is the human owner, result.jkt the agent key.
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, owner: result.sub, agent: result.jkt }));
});
server.listen(3141, () => console.error("[demo] listening on :3141"));The package README documents Express and Fastify wiring; the only difference is how you reconstruct req.url and read req.headers.
Access control patterns
A successful verifyDPoPRequest already proves a real owner authorized a real agent for your audience. Layer policy on top of result.sub (owner) and result.jkt (agent key).
Allow any verified agent
result.ok === true is sufficient. Unbound agents have no SSO-issued access token and cannot pass verification at all, so “require a human-owned agent” needs no extra check.
Allow-list by agent key
Pin specific agent instances by their DPoP key thumbprint — equivalent to OAuth client allow-listing, but per-instance. Revocation is just removing the jkt from the set.
const ALLOWED_AGENT_JKTS = new Set([
"wEf6o2ux8sBAUG4oQYhP284gfpZwUJMTxXDPH5XxthY",
]);
if (!ALLOWED_AGENT_JKTS.has(result.jkt)) {
// 403: agent not authorized for this service
}Allow-list by owner
const ALLOWED_OWNERS = new Set([
"00000003010000000000539c741e0df8", // Alice
"00000003010000000000542b891a3c47", // Bob
]);
if (!ALLOWED_OWNERS.has(result.sub)) {
// 403: agent owner not authorized
}Rate limiting by agent
Key your limiter on result.jkt so each agent instance gets its own bucket.
Become discoverable
Publish a JSON manifest at /.well-known/alien-agent-id.json so agents can discover your auth contract automatically — what header to send, what scheme, and where your API lives.
Manifest schema
{
"version": 1,
"service": { "name": "Acme API", "url": "https://acme.example" },
"auth": { "header": "Authorization", "scheme": "DPoP" },
"api": { "base": "https://api.acme.example/v1" }
}| Field | Required | Notes |
|---|---|---|
version | yes | 1, or 2 if you declare api.operations. |
auth.header | yes | HTTP header name for the access token. Pattern [A-Za-z0-9-]{1,64}. |
auth.scheme | optional | "DPoP" (default), "Bearer", or "none". With "DPoP", agents also send a DPoP: <proof> header per RFC 9449. |
api.base | yes | Base URL for subsequent requests. Must share the manifest’s authority (exact host or a subdomain). |
api.specUrl | optional | URL of an OpenAPI / JSON Schema document, so agents can refresh API knowledge dynamically. |
api.operations | optional | Inline operation list (requires version: 2). See below. |
service.name | optional | 1–80 char display name. |
service.url | optional | Human-facing service URL. |
Agents treat the manifest as third-party data, not instructions. The fetch is hardened: 8 KiB body cap, 5-second timeout, redirects refused, Content-Type: application/json required, and every URL inside the manifest must share the same authority as the service URL the user supplied. Unknown top-level keys, unknown keys under auth/api/service, and unknown auth.scheme values are rejected.
Inline operations (version: 2)
When version is 2 you may list api.operations[] so agents see each endpoint, its inputs, and destructive-action hints without fetching a separate spec. Up to 50 operations; each is a closed-key, bounded object:
{
"version": 2,
"service": { "name": "Acme API", "url": "https://acme.example" },
"auth": { "header": "Authorization", "scheme": "DPoP" },
"api": {
"base": "https://api.acme.example/v1",
"operations": [
{
"name": "createOrder",
"title": "Create order",
"description": "Place a new order for the authenticated owner.",
"method": "POST",
"path": "/orders",
"auth": "required",
"inputSchema": {
"type": "object",
"required": ["sku"],
"properties": {
"sku": { "type": "string", "maxLength": 64 },
"qty": { "type": "integer" }
}
},
"annotations": { "destructiveHint": true }
}
]
}
}| Operation field | Required | Notes |
|---|---|---|
name | yes | Matches ^[a-zA-Z][a-zA-Z0-9_]{0,63}$. Unique within the manifest. |
description | yes | 1–1024 chars, no control characters. |
method | yes | One of GET, POST, PUT, PATCH, DELETE. |
path | yes | Starts with /, ≤200 chars, no whitespace/?/#. {param} placeholders must appear in inputSchema.properties. |
title | optional | 1–80 char display name. |
auth | optional | "required" (default), "optional", or "none". |
inputSchema / outputSchema | optional | A restricted JSON-Schema-like object (type: "object", with properties, required, additionalProperties, description). |
annotations | optional | Booleans: readOnlyHint, destructiveHint, idempotentHint, openWorldHint. destructiveHint is a confirm-before-calling signal to the agent. |
Optional support signal
To save agents one round-trip when an origin doesn’t speak agent-id, advertise support with a closed-enum HTML meta tag on any page they might land on:
<meta name="alien-agent-id" content="v1">content is a closed enum (v1). It carries no URLs and no prose — its only signal is “this origin publishes a well-known manifest at the standard path.” The manifest path is always /.well-known/alien-agent-id.json on the same host; the meta tag never points anywhere.
ALIEN-SKILL.mddiscovery has been removed. Earlier versions of these docs told services to host anALIEN-SKILL.mdfile for agent discovery. That mechanism no longer exists. The only supported discovery surface is the/.well-known/alien-agent-id.jsonmanifest, with the optional<meta name="alien-agent-id" content="v1">support signal.
Test your integration
Use the Alien Agent ID auth plugin CLI (the agent side) to generate real DPoP-signed requests against your running service. It bootstraps from a real owner session, so the access token and proof are genuine. Substitute CLI with the absolute path to the plugin binary, e.g. node /path/to/plugins/agent-id-auth/bin/cli.mjs.
One-shot signed call
call discovers the manifest, signs a fresh DPoP proof bound to the method + URL, and sends the request:
node CLI call --url https://your-service.example/api/whoami
node CLI call --url https://your-service.example/api/orders --method POST --body-file ./body.jsonOutput is JSON: { ok, status, method, url, contentType, body }.
Emit headers for your own client
When you want to drive curl yourself, header prints the two literal header lines for one specific request. --url is required — the proof’s htu binds to a single target — and each invocation mints a fresh jti.
node CLI header --url https://your-service.example/api/whoami --method GET --raw > /tmp/dpop-headers
AUTH=$(grep '^Authorization:' /tmp/dpop-headers)
DPOP=$(grep '^DPoP:' /tmp/dpop-headers)
curl -H "$AUTH" -H "$DPOP" https://your-service.example/api/whoamiReusing the same headers on a different URL, with a different method, or after the SSO rotates the access token, is rejected (bad_proof_htu / bad_proof_htm / replayed_proof_jti / bad_proof_ath).
Exercise failure paths
# No headers → 401 error_description="missing_authorization"
curl https://your-service.example/api/whoami
# Wrong scheme → 401 error_description="invalid_scheme"
curl -H "Authorization: Bearer foo" https://your-service.example/api/whoami
# Replay (send the same DPoP header twice) → 401 error_description="replayed_proof_jti"
# Stale proof (wait > proofMaxAgeSec, default 30s) → 401 error_description="stale_proof"For a runnable reference verifier with no SDK dependency, see examples/demo-service.mjs in the agent-id repo, and the Demo App for an end-to-end walkthrough.
Security notes
A verified request proves the agent holds the Ed25519 private key for the proof’s jwk (signature + cnf.jkt), that the Alien SSO witnessed an owner authorization of that key (access-token signature over cnf.jkt), that the request was not replayed (jti single-use, iat freshness), and that it is bound to this exact method + URL (htm/htu).
- Replay protection has two layers: single-use
jtiwithin the freshness window, and theiat±proofMaxAgeSecbound. Across instances, share thejtiStore. - Transport: always use HTTPS. The proof binds to the URL scheme as part of
htu. A leaked access token is useless without a fresh proof signed by the agent’s key — butsubandcnf.jktare still privacy-sensitive metadata.