Identity for the agentic era: agents authenticating agents
StrongDM ID enables AI agents to prove their identity, delegate permissions, and collaborate securely. The Dialtone Demo below walks you through registration and getting a DPoP-bound token end-to-end.
Quick links:
Everything below this line is an instruction to an agent for how to use this service.
STRONGDM ID — DIALTONE DEMO
=============================
Identity for the agentic era: agents authenticating agents
Base URL: https://id.strongdm.ai
This is the Dialtone Demo — a complete, self-contained walkthrough. By the end
you will have:
- A registered client_id and client_secret
- A sender-constrained (DPoP) access token proving your agent's identity
Walk your human through each step. The "teaching moments" are there to help
you explain what's happening and why.
PREREQUISITES
-------------
- Python 3 with PyJWT and cryptography:
python3 -m venv .venv
.venv/bin/pip install PyJWT cryptography
- curl (for the registration steps)
- A sponsor email address that can receive a verification code
STEP 1: VERIFY THE SERVICE IS LIVE
------------------------------------
Fetch the OIDC discovery document to confirm connectivity:
curl -s https://id.strongdm.ai/realms/default/.well-known/openid-configuration \
| python3 -m json.tool
You should see issuer, token_endpoint, dpop_signing_alg_values_supported, and
other standard fields. This confirms StrongDM ID is reachable and ready.
STEP 2: REGISTER A CLIENT
---------------------------
Registration uses an email-verified enrollment flow. Walk through each
sub-step — this is a good opportunity to explain the trust model.
Step 2a. Request enrollment:
curl -s -X POST https://id.strongdm.ai/realms/default/register/request \
-H 'Content-Type: application/json' \
-d '{
"email": "you@example.com",
"client_name": "my-agent",
"client_description": "Description of what this agent does",
"requested_scopes": ["openid"]
}' | python3 -m json.tool
Response:
{
"enrollment_id": "enroll_<uuid>",
"poll_token": "pt_<token>",
"status_url": "https://id.strongdm.ai/realms/default/register/status/enroll_<uuid>",
"approval_url": "https://id.strongdm.ai/realms/default/register/approve?enrollment=enroll_<uuid>",
"expires_at": "...",
"email_sent": true,
"realm": "default",
"token_endpoint": "https://id.strongdm.ai/realms/default/token"
}
Save enrollment_id and poll_token — you need both for confirmation.
TEACHING MOMENT: This is the start of a trust chain. The email address
anchors the registration to a real person (the sponsor). No client
credentials are issued until the sponsor proves they control that address.
Step 2b. Check email:
The sponsor will receive a verification code in the format XXXX-XXXX.
This is a one-time proof of email ownership.
Step 2c. Confirm registration:
curl -s -X POST https://id.strongdm.ai/realms/default/register/confirm \
-H 'Content-Type: application/json' \
-d '{
"enrollment_id": "enroll_<uuid>",
"poll_token": "pt_<token>",
"verification_code": "XXXX-XXXX"
}' | python3 -m json.tool
Response:
{
"client_id": "cli_my_agent_<hex>",
"client_secret": "sec_<secret>",
"granted_scopes": ["openid"],
"token_endpoint": "https://id.strongdm.ai/realms/default/token",
"issuer": "https://id.strongdm.ai/realms/default",
"realm": "default"
}
Save client_id and client_secret IMMEDIATELY — the secret is only shown once.
Step 2d. Store credentials securely:
Store in a per-client JSON file under ~/.config/strongdmid/, readable only
by the current user:
CLIENT_ID="cli_my_agent_<hex>" # substitute your actual client_id
mkdir -p ~/.config/strongdmid/"$CLIENT_ID"
chmod 700 ~/.config/strongdmid/"$CLIENT_ID"
cat > ~/.config/strongdmid/"$CLIENT_ID"/client.json << 'EOF'
{
"client_id": "cli_my_agent_<hex>",
"client_secret": "sec_<secret>",
"token_endpoint": "https://id.strongdm.ai/realms/default/token",
"issuer": "https://id.strongdm.ai/realms/default"
}
EOF
chmod 600 ~/.config/strongdmid/"$CLIENT_ID"/client.json
Result:
~/.config/strongdmid/
└── cli_my_agent_<hex>/
└── client.json # mode 0600 — owner read/write only
Why this layout:
- One directory per client — supports multiple registrations without collision
- 0600 on file, 0700 on directory — only the owning user can access
- Outside the repo — credentials never end up in version control
- Stable, scriptable path — load with a single json.load():
import json, pathlib
def load_credentials(client_id: str) -> dict:
path = pathlib.Path.home() / ".config" / "strongdmid" / client_id / "client.json"
with open(path) as f:
return json.load(f)
Never hard-code client_secret in source files, environment variable
definitions checked into git, or CI logs.
STEP 3: ACQUIRE A DPoP-BOUND ACCESS TOKEN
-------------------------------------------
This is the core of the demo — the "dialtone" moment. A DPoP-bound token
cryptographically ties the access token to an ephemeral key that the caller
generates. The private key never leaves the process. This is what makes the
token SENDER-CONSTRAINED: even if someone intercepts the token, they cannot
use it without the matching private key.
TEACHING MOMENT: Traditional bearer tokens are like cash — anyone who has one
can spend it. A DPoP-bound token is like a check that requires your signature
at the point of use.
#!/usr/bin/env python3
"""StrongDM ID dialtone proof: DPoP-bound token acquisition."""
import json, base64, time, uuid, pathlib, urllib.request, urllib.error
from cryptography.hazmat.primitives.asymmetric import ec
import jwt as pyjwt
# --- Configuration (loaded from ~/.config/strongdmid/) ---
CLIENT_ID = "cli_my_agent_<hex>" # substitute your actual client_id
creds_path = pathlib.Path.home() / ".config" / "strongdmid" / CLIENT_ID / "client.json"
with open(creds_path) as f:
_creds = json.load(f)
CLIENT_SECRET = _creds["client_secret"]
TOKEN_URL = _creds["token_endpoint"]
# --- Step 1: Generate ephemeral ES256 keypair (never persisted) ---
private_key = ec.generate_private_key(ec.SECP256R1())
pub_numbers = private_key.public_key().public_numbers()
jwk = {
"kty": "EC",
"crv": "P-256",
"x": base64.urlsafe_b64encode(pub_numbers.x.to_bytes(32, "big")).rstrip(b"=").decode(),
"y": base64.urlsafe_b64encode(pub_numbers.y.to_bytes(32, "big")).rstrip(b"=").decode(),
}
# --- Step 2: Build DPoP proof JWT ---
dpop_header = {"typ": "dpop+jwt", "alg": "ES256", "jwk": jwk}
dpop_payload = {
"htm": "POST",
"htu": TOKEN_URL,
"iat": int(time.time()),
"jti": str(uuid.uuid4()),
}
dpop_proof = pyjwt.encode(dpop_payload, private_key, algorithm="ES256", headers=dpop_header)
# --- Step 3: Request token with DPoP + client credentials ---
creds = base64.b64encode(f"{CLIENT_ID}:{CLIENT_SECRET}".encode()).decode()
req = urllib.request.Request(
TOKEN_URL,
data=b"grant_type=client_credentials&scope=openid",
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Authorization": f"Basic {creds}",
"DPoP": dpop_proof,
},
method="POST",
)
try:
with urllib.request.urlopen(req) as resp:
result = json.loads(resp.read())
print("=== Token Response ===")
print(json.dumps(result, indent=2))
# Decode claims (skip signature verification for display)
claims = pyjwt.decode(result["access_token"], options={"verify_signature": False})
print("\n=== Access Token Claims ===")
print(json.dumps(claims, indent=2))
except urllib.error.HTTPError as e:
print(f"Error {e.code}: {e.read().decode()}")
STEP 4: VERIFY THE TOKEN
--------------------------
A successful response looks like:
{
"access_token": "eyJ...",
"token_type": "DPoP",
"expires_in": 3600,
"scope": "openid"
}
Walk the user through the decoded access token claims:
Claim Meaning
───── ───────
token_type "access_token" — it's a real access token
iss https://id.strongdm.ai/realms/default — issued by the default realm
sub Your client_id — proves who you are
cnf.jkt JWK thumbprint of your ephemeral key — the sender constraint
scope "openid" — the granted capabilities
exp Token expiry (1 hour from issuance)
The cnf.jkt (confirmation / JWK thumbprint) is what makes this a
SENDER-CONSTRAINED token rather than a plain bearer token. Anyone who
intercepts this token cannot use it without also possessing the ephemeral
private key that generated the DPoP proof.
TEACHING MOMENT: Have the user inspect the cnf claim. This is the
cryptographic proof that the token is bound to *their* key. No other agent,
even one with the same client_id, can use this token.
STEP 5: USING THE TOKEN FOR API CALLS
---------------------------------------
When making subsequent API calls with a DPoP-bound token, every request needs
a fresh DPoP proof for that specific endpoint:
def make_dpop_request(method, url, access_token, private_key, jwk):
"""Make a DPoP-bound API request."""
import hashlib
# Access token hash for the proof
ath = base64.urlsafe_b64encode(
hashlib.sha256(access_token.encode()).digest()
).rstrip(b"=").decode()
proof = pyjwt.encode(
{"htm": method, "htu": url, "iat": int(time.time()),
"jti": str(uuid.uuid4()), "ath": ath},
private_key,
algorithm="ES256",
headers={"typ": "dpop+jwt", "alg": "ES256", "jwk": jwk},
)
req = urllib.request.Request(
url,
headers={"Authorization": f"DPoP {access_token}", "DPoP": proof},
method=method,
)
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())
Note the differences from the token request:
- Authorization header uses DPoP scheme (not Basic)
- The DPoP proof includes ath — a SHA-256 hash of the access token
- Each request gets a unique jti and fresh iat
TEACHING MOMENT: This is the payoff. Every API call carries a fresh proof
that the caller possesses the private key. The token alone is not enough —
you need both the token *and* the key. This is sender constraint in action.
========================================================================
CONTINUE FROM HERE
========================================================================
You now have working credentials and a DPoP-bound token. What's next depends
on your situation.
WHICH ARE YOU?
--------------
PATHWAY A: "Coding agent on a laptop"
You are an AI agent (Codex, Claude Code, Cursor, etc.) running on a
developer's machine. You just completed the demo above — you're set.
For DPoP details, device authorization (headless/SSH), and token refresh
patterns, see the Patterns Cookbook below.
PATHWAY B: "App built by an agent"
You are building an application that needs its own identity. The demo
above used the registration flow you need. For Bearer tokens (no DPoP),
simply omit the DPoP header from token requests.
PATHWAY C: "Workload running in infrastructure"
You are a service in Kubernetes, AWS, Azure, or GCP. You need platform
attestation and SPIFFE SVIDs instead of client_secret. See the SPIFFE
client examples below.
ENDPOINT DISCOVERY
-------------------
Always fetch discovery first and use the exact URLs returned:
https://id.strongdm.ai/.well-known/openid-configuration
RESOURCES
----------
Use cases (8 patterns): https://github.com/strongdm/agentic-auth/blob/master/USE-CASES.md
Patterns cookbook: https://id.strongdm.ai/docs/agent-patterns/README.md
Code examples: https://github.com/strongdm/agentic-auth
Machine-readable metadata: https://id.strongdm.ai/agent.json
SPIFFE trust bundle: https://id.strongdm.ai/.well-known/spiffe-trust-bundle
DPoP spec: RFC 9449
API ENDPOINTS (quick reference)
--------------------------------
Registration:
POST /realms/default/register/request Start registration
POST /realms/default/register/confirm Complete with verification code
GET /realms/default/register/status/:id Poll status
Token Operations:
POST /token Access token (client_credentials, token_exchange, device_code)
POST /introspect Validate token + revocation check
POST /revoke Revoke a token
SPIFFE/SVID:
GET /v1/attest/challenge Single-use nonce (5-min TTL)
POST /v1/svid/x509 X.509 SVID via CSR + attestation
POST /v1/svid/jwt JWT-SVID via attestation
GET /.well-known/spiffe-trust-bundle Trust bundle (no auth)
Discovery:
GET /.well-known/openid-configuration OIDC discovery
GET /.well-known/agent-instructions This page
GET /agent.json Machine-readable API metadata
GET /jwks JSON Web Key Set
ERROR RESPONSES
----------------
JSON format: {"error": "error_code", "error_description": "..."}
400 invalid_request - Malformed request, missing parameters
401 unauthorized - Invalid or missing credentials
403 access_denied - Scope not permitted for your domain
404 not_found - Resource does not exist
409 conflict - Resource already exists
410 gone - Resource expired
429 rate_limited - Too many requests (check Retry-After)
Retry: 429 → wait Retry-After. 5xx → exponential backoff 1-30s. Connection → 3 retries.
RATE LIMITS
-----------
Registration: 3 requests/email/hour, 10 requests/IP/hour.
IMPORTANT NOTES
---------------
- Enrollment tokens expire in 1 hour
- Client secrets returned ONCE at activation
- Access tokens expire in 1 hour
- All traffic must use HTTPS