Skip to Content
Integrate Agent SSO
View .md

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" } }
ClaimWhat it attests
issThe issuing SSO (https://sso.alien-api.com).
subThe human owner’s AlienID address.
audThe token audience. The Alien SSO emits aud = [client_id, issuer].
expAccess-token expiry (NumericDate).
cnf.jktRFC 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-id

Zero 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>
ParameterTypeDefaultDescription
ssoBaseUrlstringDEFAULT_SSO_BASE_URLSSO 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-hosted

verifyDPoPRequest(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, ): VerifyDPoPResult

req 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:

OptionTypeDefaultDescription
jwksJWKS— (required)Pre-fetched SSO JWKS (see fetchAlienJWKS).
expectedIssuerstring"https://sso.alien-api.com"Required access-token iss. Override for staging/self-hosted SSO.
expectedAudiencestring | falseexpectedIssuerFederated audience (see below). String to scope to one client_id/resource; false to skip (test fixtures only).
proofMaxAgeSecnumber30DPoP proof freshness window, in seconds. The proof’s iat must be within ±this of now.
clockSkewSecnumber30Clock-skew allowance, in seconds, applied to the access token’s exp.
jtiStoreDPoPJtiStorein-memory MapReplay-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:

codeMeaning
missing_authorizationNo (or duplicate) Authorization header.
invalid_schemeAuthorization is not DPoP <access_token>.
missing_dpopNo (or duplicate) DPoP proof header.
malformed_proofDPoP proof is not a valid JWS.
bad_proof_typProof typ is not dpop+jwt.
bad_proof_algProof alg is not EdDSA.
missing_proof_jwkProof header has no jwk.
bad_proof_jwkProof jwk is not a {kty:OKP, crv:Ed25519, x} key.
private_in_proof_jwkProof jwk leaks the private member d.
proof_sig_errorProof signature verification threw.
bad_proof_signatureProof signature failed verification.
bad_proof_htmProof htm ≠ request method.
bad_proof_htuProof htu ≠ request URL (or not a parseable URL).
bad_proof_iatProof iat is not a NumericDate.
stale_proofProof age exceeds proofMaxAgeSec.
future_proofProof iat is too far in the future.
missing_proof_jtiProof has no jti.
replayed_proof_jtiProof jti has been seen before.
malformed_access_tokenAccess token is not a valid JWS.
bad_access_token_typAccess-token typ is not at+jwt.
bad_access_token_algAccess-token alg is not RS256.
unknown_access_token_kidNo JWKS entry matches the access-token kid.
access_token_sig_errorAccess-token signature verification threw.
bad_access_token_signatureAccess-token signature failed verification.
bad_access_token_issAccess-token issexpectedIssuer.
bad_access_token_audAccess-token aud does not include expectedAudience.
expired_access_tokenAccess token is expired (past exp + clockSkewSec).
missing_access_token_subAccess token has no sub.
missing_cnf_jktAccess token has no cnf.jkt.
jkt_mismatchcnf.jkt ≠ thumbprint of the proof’s jwk.
bad_proof_athProof athsha256(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" } }
FieldRequiredNotes
versionyes1, or 2 if you declare api.operations.
auth.headeryesHTTP header name for the access token. Pattern [A-Za-z0-9-]{1,64}.
auth.schemeoptional"DPoP" (default), "Bearer", or "none". With "DPoP", agents also send a DPoP: <proof> header per RFC 9449.
api.baseyesBase URL for subsequent requests. Must share the manifest’s authority (exact host or a subdomain).
api.specUrloptionalURL of an OpenAPI / JSON Schema document, so agents can refresh API knowledge dynamically.
api.operationsoptionalInline operation list (requires version: 2). See below.
service.nameoptional1–80 char display name.
service.urloptionalHuman-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 fieldRequiredNotes
nameyesMatches ^[a-zA-Z][a-zA-Z0-9_]{0,63}$. Unique within the manifest.
descriptionyes1–1024 chars, no control characters.
methodyesOne of GET, POST, PUT, PATCH, DELETE.
pathyesStarts with /, ≤200 chars, no whitespace/?/#. {param} placeholders must appear in inputSchema.properties.
titleoptional1–80 char display name.
authoptional"required" (default), "optional", or "none".
inputSchema / outputSchemaoptionalA restricted JSON-Schema-like object (type: "object", with properties, required, additionalProperties, description).
annotationsoptionalBooleans: 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.md discovery has been removed. Earlier versions of these docs told services to host an ALIEN-SKILL.md file for agent discovery. That mechanism no longer exists. The only supported discovery surface is the /.well-known/alien-agent-id.json manifest, 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.json

Output 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/whoami

Reusing 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 jti within the freshness window, and the iat ±proofMaxAgeSec bound. Across instances, share the jtiStore.
  • 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 — but sub and cnf.jkt are still privacy-sensitive metadata.
Last updated on