Skip to content

Third-Party Integration — Verify Fluxa Agent ID

This guide is for third-party services that want to accept "Sign in with Fluxa Agent ID" — i.e. let an AI agent authenticate against your backend using its Fluxa Agent ID instead of email/password or a per-service API key.

It is the verifier-side counterpart to Log in to a Third-Party Service with Agent ID.

What you get

A trusted, cryptographically signed assertion that:

  • A specific agent_id (issued by FluxA) controls the bearer of the credential.
  • The credential is bound to your service (via the aud claim).
  • The credential is bound to a specific challenge you generated (so it cannot be replayed against another login attempt).
  • The credential expires within a short TTL chosen by the agent (≤ 24 h).

The credential — a Verifiable Credential (VC) — is an RS256-signed JWT. You verify it locally with FluxA's public keys; you do not need to call FluxA at request time.

What you do not get

  • The agent's email, wallet address, or balance — the VC carries only sub (the agent_id) plus binding claims. If you need more, ask the agent to provide it through your own onboarding form.
  • Any payment authorization. The VC is identity-only.
  • A long-lived token. Mint your own session/refresh tokens after verification.

Minimum integration

You need three things on your side:

  1. A challenge endpoint — generates a random nonce, stores it server-side keyed to the agent attempt, returns it to the agent together with your audience identifier.
  2. A callback endpoint — receives a VC from the agent, verifies it, mints your own session.
  3. A JWKS cache — fetched once at startup (and refreshed periodically) from FluxA.

FluxA endpoints you depend on

EndpointPurposeCalled from
GET https://agentid.fluxapay.xyz/.well-known/jwks.jsonPublic RS256 keys used to sign VCsYour server, at startup and on kid cache miss
POST https://agentid.fluxapay.xyz/verify-vcOptional helper: verify a VC without a JWT libraryYour server, only if you can't run a JWT lib locally

Run local verification in production. The helper endpoint exists for debugging and for environments that genuinely can't link a JWT library.

End-to-end flow

┌─────────┐                        ┌──────────────────┐                    ┌──────────────────┐
│  Agent  │                        │  Your service    │                    │ FluxA Agent ID   │
└────┬────┘                        └────────┬─────────┘                    └────────┬─────────┘
     │                                      │                                       │
     │                                      │  0. GET /.well-known/jwks.json        │
     │                                      ├──────────────────────────────────────►│
     │                                      │     (once at startup, cache by kid)   │
     │                                      │◄──────────────────────────────────────┤
     │                                      │                                       │
     │  1. POST /auth/fluxa/start           │                                       │
     ├─────────────────────────────────────►│                                       │
     │  2. { challenge, audience }          │                                       │
     │◄─────────────────────────────────────┤  (store challenge → attempt server-   │
     │                                      │   side; short TTL, single-use)        │
     │                                      │                                       │
     │  3. POST /agent/vc/issue (Bearer login JWT)                                  │
     ├──────────────────────────────────────────────────────────────────────────────►│
     │  4. { vc, … }                                                                 │
     │◄──────────────────────────────────────────────────────────────────────────────┤
     │                                      │                                       │
     │  5. POST /auth/fluxa/callback { vc } │                                       │
     ├─────────────────────────────────────►│                                       │
     │                                      │  6. local verify (jwt.verify, typ,    │
     │                                      │     aud, challenge, exp); mark        │
     │                                      │     challenge consumed                │
     │  7. { session token }                │                                       │
     │◄─────────────────────────────────────┤                                       │

Verification checklist

For every VC you receive, in order:

  1. Decode the header and assert header.typ === "agent-vc". Reject otherwise. This is the boundary that keeps a login JWT from being accepted as a VC.
  2. Look up the public key by header.kid in your JWKS cache. If unknown, refresh the JWKS once and try again; if still unknown, reject.
  3. Verify the signature with algorithms: ['RS256']. Never accept alg: none, never accept HS256.
  4. Verify exp is in the future (jsonwebtoken.verify does this for you).
  5. Verify iss === "fluxa-agent-did".
  6. Verify payload.aud === <your audience identifier>. Use exact string match — pick one canonical value (typically your domain) and require it.
  7. Verify payload.challenge equals the challenge you generated for this login attempt. Reject if missing, mismatched, already consumed, or expired on your side.
  8. Mark the challenge consumed before issuing your session token, so a replay of the same VC after step-7 success still fails.
  9. Use payload.sub as the canonical agent_id. This is what you store in your users table.

A failure in any one of these is a hard rejection. Do not "fall back" to a weaker check.

Reference verifier (Node.js)

js
import express from 'express'
import jwt from 'jsonwebtoken'
import jwksClient from 'jwks-rsa'
import crypto from 'node:crypto'

const AUDIENCE = 'https://thirdparty.example.com'
const ISSUER = 'fluxa-agent-did'

const jwks = jwksClient({
  jwksUri: 'https://agentid.fluxapay.xyz/.well-known/jwks.json',
  cache: true,
  cacheMaxAge: 10 * 60 * 1000, // 10 min
  rateLimit: true,
})

function getKey(header, cb) {
  jwks.getSigningKey(header.kid, (err, key) => {
    if (err) return cb(err)
    cb(null, key.getPublicKey())
  })
}

// In-memory challenge store; replace with Redis in production.
const challenges = new Map() // challenge -> { agentAttemptId, expiresAt, consumed }

const app = express()
app.use(express.json())

// 1. Issue a challenge.
app.post('/auth/fluxa/start', (req, res) => {
  const challenge = crypto.randomBytes(24).toString('base64url')
  challenges.set(challenge, {
    expiresAt: Date.now() + 5 * 60 * 1000,
    consumed: false,
  })
  res.json({ challenge, audience: AUDIENCE, ttl_seconds: 300 })
})

// 2. Verify the VC.
app.post('/auth/fluxa/callback', (req, res) => {
  const { vc } = req.body ?? {}
  if (!vc) return res.status(400).json({ error: 'vc required' })

  // Step 1: header.typ must be agent-vc.
  const decoded = jwt.decode(vc, { complete: true })
  if (!decoded || decoded.header?.typ !== 'agent-vc') {
    return res.status(401).json({ error: 'not_a_vc' })
  }

  // Steps 2–4: signature + exp via jsonwebtoken.
  jwt.verify(
    vc,
    getKey,
    { algorithms: ['RS256'], issuer: ISSUER, audience: AUDIENCE },
    (err, payload) => {
      if (err) return res.status(401).json({ error: 'invalid_or_expired_vc' })

      // Steps 7–8: challenge check + single use.
      const entry = challenges.get(payload.challenge)
      if (!entry || entry.consumed || entry.expiresAt < Date.now()) {
        return res.status(401).json({ error: 'challenge_invalid' })
      }
      entry.consumed = true

      // Step 9: payload.sub is the agent_id.
      const agentId = payload.sub
      // …upsert user, mint your own session, return it.
      res.json({ agent_id: agentId, access_token: mintSession(agentId) })
    },
  )
})

function mintSession(agentId) {
  // your own session JWT / opaque token
  return crypto.randomBytes(24).toString('base64url')
}

app.listen(3000)

The jsonwebtoken audience and issuer options will reject mismatches automatically; we still keep the explicit typ check above because jsonwebtoken does not validate header.typ for you.

Reference verifier (Python)

python
import os
import time
import secrets
from flask import Flask, request, jsonify
from jose import jwt
from jose.exceptions import JWTError
import requests

AUDIENCE = "https://thirdparty.example.com"
ISSUER = "fluxa-agent-did"
JWKS_URL = "https://agentid.fluxapay.xyz/.well-known/jwks.json"

_jwks_cache = {"keys": [], "fetched_at": 0}

def jwks():
    if time.time() - _jwks_cache["fetched_at"] > 600:
        _jwks_cache["keys"] = requests.get(JWKS_URL, timeout=5).json()["keys"]
        _jwks_cache["fetched_at"] = time.time()
    return _jwks_cache["keys"]

def key_for(kid):
    for k in jwks():
        if k["kid"] == kid:
            return k
    _jwks_cache["fetched_at"] = 0  # force refresh on next call
    return None

challenges = {}  # challenge -> {"expires_at": ts, "consumed": False}

app = Flask(__name__)

@app.post("/auth/fluxa/start")
def start():
    challenge = secrets.token_urlsafe(24)
    challenges[challenge] = {"expires_at": time.time() + 300, "consumed": False}
    return jsonify(challenge=challenge, audience=AUDIENCE, ttl_seconds=300)

@app.post("/auth/fluxa/callback")
def callback():
    vc = (request.json or {}).get("vc")
    if not vc:
        return jsonify(error="vc required"), 400

    header = jwt.get_unverified_header(vc)
    if header.get("typ") != "agent-vc":
        return jsonify(error="not_a_vc"), 401

    key = key_for(header.get("kid"))
    if not key:
        return jsonify(error="unknown_kid"), 401

    try:
        payload = jwt.decode(
            vc,
            key,
            algorithms=["RS256"],
            audience=AUDIENCE,
            issuer=ISSUER,
        )
    except JWTError:
        return jsonify(error="invalid_or_expired_vc"), 401

    entry = challenges.get(payload.get("challenge"))
    if not entry or entry["consumed"] or entry["expires_at"] < time.time():
        return jsonify(error="challenge_invalid"), 401
    entry["consumed"] = True

    agent_id = payload["sub"]
    # upsert user, mint your own session
    return jsonify(agent_id=agent_id, access_token=secrets.token_urlsafe(24))

Optional: the /verify-vc helper

If you genuinely cannot run a JWT library — e.g. a no-code platform or an early prototype — you can POST the VC to FluxA and let the server check it. Trade-off: each login becomes a network round trip to FluxA, so plan for the latency and the dependency.

bash
curl -X POST https://agentid.fluxapay.xyz/verify-vc \
  -H "Content-Type: application/json" \
  -d '{
    "vc": "eyJhbGci…",
    "expected_audience": "https://thirdparty.example.com",
    "expected_challenge": "f3c1e7…-random-nonce"
  }'

Response shape:

json
{
  "valid": true,
  "payload": {
    "typ": "agent-vc",
    "sub": "<agent_id>",
    "iss": "fluxa-agent-did",
    "aud": "https://thirdparty.example.com",
    "jti": "5b1f7e0b-…",
    "challenge": "f3c1e7…-random-nonce",
    "iat": 1700000000,
    "exp": 1700000300
  }
}

Or, on failure, one of:

json
{ "valid": false, "error": "invalid_or_expired_vc" }
{ "valid": false, "error": "audience_mismatch" }
{ "valid": false, "error": "challenge_mismatch" }

You still have to enforce challenge single-use on your side — the helper only checks the value, not whether you've already consumed it.

Storing the agent

After a successful verify, the canonical key for the user is payload.sub. Treat it as you would a sub from any OIDC provider:

  • One row per agent_id in your users (or agents) table.
  • Use it for foreign keys, rate limits, audit logs.
  • If your product allows linking multiple Agent IDs to one human account, store the link from agent_id to your internal user id.

The VC carries only the agent_id (in sub) — no email, display name, or wallet address. Collect any extra profile fields you need through your own onboarding step.

Common verification mistakes

  • Trusting header.alg — always pass algorithms: ['RS256'] to the verifier. Don't let the token pick its own algorithm.
  • Skipping the typ checkjsonwebtoken.verify accepts any typ. You must check header.typ === "agent-vc" yourself, or a login JWT could be replayed against your audience.
  • Loose aud match — compare exact strings. Don't accept any URL on your domain; pick one canonical audience and pin it.
  • Reusing a challenge — mark it consumed atomically with issuing your session token. Without single-use, a stolen VC is a replay token until exp.
  • Caching JWKS without kid refresh — if a kid you don't have shows up, refresh once before rejecting; FluxA may rotate keys.
  • Logging the raw VC — it's a bearer credential until exp. Log jti if you need correlation; redact the rest.

Operational notes

  • Key rotation: FluxA may publish multiple keys in JWKS. Always select by header.kid; never assume a single key.
  • Revocation: the MVP has no jti blacklist. Mitigate via short TTL (≤ 5 min for interactive login is fine) and challenge single-use.
  • Clock skew: jsonwebtoken allows a small clockTolerance (e.g. 30 s) — set it if your servers' clocks drift.
  • Audit: log jti, agent_id, aud, iat, exp, and the consumed challenge fingerprint. Do not log the full VC.

See also: VC HTTP API reference and the agent-side guide Log in to a Third-Party Service with Agent ID.

Released under the MIT License.