Prove and verify identity
Some websites and APIs ask an agent to prove that Link vouches for it, or to share verified details about the customer, before they allow access. Agents get these credentials from Link with Link CLI’s identity commands. Services verify them with the Agent Identity SDK (@stripe/agent-identity). To learn how the credentials and challenges work, see Identity.
Use credentials from an agent
Follow these steps to answer a service’s challenges with Link CLI.
Before you begin
- Install Link CLI globally, as described in Global install, and log in with
link-cli auth login. Only therequestcommands need Link authentication.list,pop, andpresentuse local files. - Use the installed
link-clicommand. Identity commands appear in--helpand--llms, but Link CLI doesn’t expose them as MCP tools. - To teach a compatible agent this flow, install the
use-link-identityskill as described in Install the skills.
Ensure agent access
Identity commands need the userinfo:read scope, which the default login includes. An agent that uses only identity, and no payments or financial data, can log in with only that scope:
link-cli auth login --client-name "My Agent" --scope "userinfo:read"If Link CLI is already logged in, auth login keeps the current session. Run link-cli auth logout first, and confirm the granted scopes with link-cli auth status.
Answer an attestation challenge
Request attestations
Request a batch of tokens. --count is required and accepts a value from 1 to 100.
link-cli identity attestations request --count 10 --format jsonLink CLI adds the tokens to its local pool. The output contains only the file path and metadata, never the tokens.
Pop a token
When a service sends an attestation challenge, remove one token from the pool:
link-cli identity attestations pop --format json{
"issuer": "https://api.link.com",
"token_key_id": "<issuer-key-id>",
"token": "<token>",
"authorization": "PrivateToken token=\"<token-with-header-padding>\""
}If the pool is empty, the command returns ATTESTATION_POOL_EMPTY. Request another batch, then pop again.
Retry with the token
Retry the challenged request with the authorization value as the Authorization header. Use the value as-is, including its scheme and quotes. Keep the same token for retries in the same interaction, and pop a new token for a different service. Don’t return a popped token to the pool.
To allocate tokens yourself, export a batch to a new file outside ~/.link-cli/identity/attestations instead:
link-cli identity attestations request --count 10 --output-file ./aats.json --format jsonRead the tokens array and the authorization value of each entry programmatically. Exported tokens never enter the pool, and Link CLI never imports them. Your agent owns their allocation, concurrency, and cleanup.
Present identity claims
Request a credential
Request a signed credential if no usable one exists:
link-cli identity credentials request --format jsonLink CLI saves the credential and binds it to a local holder key, as described in Local storage. The output includes the file path, issuer, expiry, holder-key path and thumbprint, and claim names, but no claim values.
Read the challenge
A service that needs claims returns HTTP 401 with WWW-Authenticate: Identity-Presentation and a body like this:
{
"type": "urn:stripe:link:claims-required",
"aud": "https://shop.example",
"nonce": "<generated nonce>",
"claims": ["email", "email_verified"],
"purpose": "Register for an event",
"formats": ["dc+sd-jwt"],
"trusted_issuers": ["https://api.link.com"]
}Confirm that the audience belongs to the intended service and that the customer authorized sharing the requested claims for this task.
Create a presentation
Sign the requested claims with the exact audience and nonce from the challenge. Repeat --claim for each claim. --aud, --nonce, and at least one --claim are required. Pass the challenge values as separate process arguments, or quote them safely in a shell.
link-cli identity credentials present \
--aud 'https://shop.example' \
--nonce '<nonce-from-challenge>' \
--claim email \
--claim email_verified \
--format json{"presentation":"<issuer-jwt>~<disclosures>~<key-binding-jwt>"}present signs locally with the saved holder key. It doesn’t need login or network access, and it doesn’t change the saved credential or key. It fails instead of returning a presentation if a claim is missing, the credential has expired or uses an unsupported disclosure format, or the holder key doesn’t match.
Retry with the presentation
Send the presentation value as the Identity-Presentation header. Send it promptly, because it includes a fresh signing time. Keep the method, body, session, any interaction identifier, and any required Authorization header from the challenged request. If the service rejects the nonce or the nonce has expired, get a new challenge and present again. You can reuse the saved credential until it expires.
If a command fails, see Agent errors.
Verify credentials in your service
Use the Agent Identity SDK to send challenges and verify the credentials that agents return. Your service decides when to ask for each check and what a successful result allows. The SDK uses only Link’s public metadata and verification keys. It doesn’t need the agent’s Link access token or a Stripe secret key.
Create a verifier
Install the package from npm. It supports Node.js 22 or newer, with ESM and CommonJS builds.
npm install @stripe/agent-identityCreate one LinkVerifier per process so that all requests share its issuer key cache:
import { LinkVerifier, VerificationError, isRejection } from '@stripe/agent-identity'
const verifier = new LinkVerifier({ origin: 'https://shop.example' })origin sets the audience for identity claims. It accepts a host, a host and port, or a full origin, and a bare host defaults to HTTPS. Set it from trusted configuration, not from an incoming Host or forwarding header. It doesn’t restrict where an agent can present an AAT.
Optional settings are timeoutMs (default 3000), fetchImpl, now (returns Unix seconds), and issuerOptions. Use issuerOptions.issuer only for staging and tests. Link is the only supported issuer.
Use HTTPS for endpoints that receive credentials. The SDK receives strings and can’t enforce transport security.
Require an attestation
Verify the Authorization header. If the SDK rejects it, return HTTP 401 with an attestation challenge:
export async function handle(request: Request): Promise<Response> {
const attestation = await requireAttestation(request)
if (attestation instanceof Response) return attestation
// Apply your authorization policy before you perform the action.
return Response.json({ issuer: attestation.issuer })
}
async function requireAttestation(request: Request) {
try {
return await verifier.verifyAttestationOrThrow(request.headers.get('Authorization'))
} catch (error) {
if (!(error instanceof VerificationError && isRejection(error))) {
return Response.json({ code: 'issuer_unavailable' }, { status: 503 })
}
return attestationRequired(error)
}
}
async function attestationRequired(error: VerificationError): Promise<Response> {
try {
const challenge = await verifier.attestationChallenge()
const headers = new Headers()
for (const value of challenge.wwwAuthenticate) headers.append('WWW-Authenticate', value)
return Response.json({ code: error.code, message: error.message }, { status: 401, headers })
} catch {
return Response.json({ code: 'issuer_unavailable' }, { status: 503 })
}
}isRejection separates credential rejections, which get a 401 and a new challenge, from issuer availability failures, which get a 503. Return the error message with the challenge. For attestation rejections, it tells the agent how to set up Link Agent Wallet and retry. Send each challenge value as a separate WWW-Authenticate field.
Reject duplicate Authorization headers before you call the verifier. In Node.js or Express, use req.headersDistinct:
const values = req.headersDistinct.authorization
if (values !== undefined && values.length !== 1) {
return res.status(400).json({ code: 'malformed_protocol_input' })
}
const { issuer } = await verifier.verifyAttestationOrThrow(values?.[0])Require identity claims
Your service can request claims with or without an attestation. Each check has its own API and access policy.
Send a claims challenge
Build a challenge for the claims you need and save its nonce with the pending operation:
const challenge = await verifier.claimsChallenge({
claims: ['email', 'email_verified'],
purpose: 'Register for an event',
})
await interactions.save(interactionId, {
nonce: challenge.nonce,
expiresAt: challenge.expiresAt,
requiredClaims: ['email', 'email_verified'],
})
res.setHeader('WWW-Authenticate', challenge.wwwAuthenticate)
res.setHeader('Content-Type', 'application/problem+json')
res.status(401).json(challenge.body)challenge.body is the JSON body shown in Read the challenge. interactions and interactionId stand for your application’s own interaction store. The SDK doesn’t persist the nonce. Save the nonce and expiresAt (Unix seconds) for this caller and operation, and share the store across replicas.
nonceTtlSeconds sets expiresAt and defaults to 300. The SDK doesn’t enforce it. Before you authorize the operation, reject expired or completed interactions, and check that the stored operation and caller match the request. If your service has no session, return an opaque interaction ID and have the client send it back on retry.
Requesting a claim that Link doesn’t advertise throws an error. Call claimsSupported() to list the supported claims.
Verify the presentation
Verify the Identity-Presentation header against the nonce you saved:
const { claims } = await verifier.verifyClaimsOrThrow(request.headers.get('Identity-Presentation'), {
nonce: savedInteraction.nonce,
requiredClaims: ['email', 'email_verified'],
})
if (claims.email_verified === true) {
// Complete the registration with claims.email.
}If the presentation fails, verifyClaimsOrThrow throws a VerificationError. When isRejection(error) is true, return 401 with the error code, and keep an unexpired interaction pending so the agent can present again. Otherwise, return 503.
Pass the nonce from your stored interaction, never from the presentation. requiredClaims is required. Pass [] to require no claims. For a verified email, require both email and email_verified, and check that email_verified is true.
The SDK checks the issuer signature, the disclosures, credential expiry with no clock skew, and the holder-signed key binding for audience, nonce, and sd_hash. The result also includes holderKeyThumbprint, issuer, and vct. Check the type of each claim and apply your business rules.
Decide whether a successful verification permits one operation, one resource, or an application session. Later requests can use your own session credential instead of a new presentation. The SDK doesn’t enforce single use or expiry. See Responsibilities for what your service must enforce.
Test your integration
LinkFixture and CredentialFixture from @stripe/agent-identity/testing create real signed tokens and presentations, including deliberately invalid ones, without calls to Link:
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { LinkVerifier, VerificationError } from '@stripe/agent-identity'
import { LinkFixture } from '@stripe/agent-identity/testing'
test('accepts a Link token and rejects a forged token', async () => {
const link = await LinkFixture.create()
const verifier = new LinkVerifier({ origin: 'shop.example', fetchImpl: link.fetchImpl() })
const token = await link.mint()
await verifier.verifyAttestationOrThrow(token.authorization)
const forged = await link.mint({ corruptAuthenticator: true })
await assert.rejects(verifier.verifyAttestationOrThrow(forged.authorization), (error) => {
assert.ok(error instanceof VerificationError)
assert.equal(error.code, 'invalid_private_token')
return true
})
})For identity claims, use CredentialFixture.present() with combineFetch to serve both Link metadata and credential keys. Call clearJwksCache() between tests that create new keys for the same issuer.
The examples also include a credential verification script that runs with fixtures.
Next steps
Use these references to understand the model and run the complete flow:
Review credentials, local storage, responsibilities, errors, and the SDK reference.
Run an HTTP service that uses both checks, with fixtures or Link CLI.
Install the identity skill so your agent follows this flow.