StrongDM ID

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.


AGENT INSTRUCTIONS — DIALTONE DEMO
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