Verifying Credentials

Alongside the claims list, GET /users/{userId}/claims returns credential: the same claims as an SD-JWT signed by INFRA with ES256. Verify it in your own backend with INFRA's public keys, without calling INFRA on every check.

  1. Read the user's claims — Call GET /api/v1/users/{userId}/claims with a claims:read token. The response includes credential and credential_format: "dc+sd-jwt".
  2. Fetch INFRA's public keys — Load https://api.infraidentity.com/.well-known/jwks.json and cache it (up to 5 minutes). Pick the key whose kid matches the credential header.
  3. Verify the signature and checks — Check the ES256 signature, issuer, expiry, that aud is your own client_id, and that environment matches your mode (live or test).
  4. Read the disclosed claims — Each claim is a disclosure: hash it with SHA-256 and confirm the digest is listed in the signed _sd array before trusting it.

Response#

{
  "success": true,
  "data": {
    "user_id": "a3f1…",
    "claims": [
      { "claim_type": "kyc_verified", "provider": "persona", "proof_hash": "…", "expires_at": null }
    ],
    "credential": "eyJhbGciOiJFUzI1NiIsInR5cCI6ImRjK3NkLWp3dCIsImtpZCI6Ii4uLiJ9.eyJ…~WyJzYWx0Ii…~",
    "credential_format": "dc+sd-jwt"
  }
}

Verify in Node.js#

import { createRemoteJWKSet, jwtVerify } from 'jose';
import { createHash } from 'node:crypto';

const JWKS = createRemoteJWKSet(
  new URL('https://api.infraidentity.com/.well-known/jwks.json')
);

export async function verifyInfraCredential(credential, { clientId, mode = 'live' }) {
  const [jwt, ...rest] = credential.split('~');
  const disclosures = rest.slice(0, -1); // the credential ends with "~"

  const { payload } = await jwtVerify(jwt, JWKS, {
    algorithms: ['ES256'],
    issuer: 'https://api.infraidentity.com',
    audience: clientId,        // must be YOUR client_id
    typ: 'dc+sd-jwt',
  });
  if (payload.environment !== mode) {
    throw new Error(`Credential is for ${payload.environment}, not ${mode}`);
  }

  const signed = new Set(payload._sd);
  const claims = {};
  for (const d of disclosures) {
    const digest = createHash('sha256').update(d).digest('base64url');
    if (!signed.has(digest)) throw new Error('Disclosure not covered by the signature');
    const [, name, value] = JSON.parse(Buffer.from(d, 'base64url').toString('utf8'));
    claims[name] = value; // e.g. kyc_verified: { verified: true, provider, issued_at, expires_at }
  }
  return { userId: payload.sub, claims };
}

Rules#

  • Always check aud against your own client_id and environment against your mode: a test credential must never pass in live.
  • Credentials expire after 24 hours; read the claims again for a fresh one. Each claim also carries its own expires_at.
  • You can forward a subset of disclosures to a partner (selective disclosure); the signature still verifies.
  • Keys rotate: always select by kid from the JWKS, and refresh the JWKS when you see an unknown kid.
  • These are signed verification claims, not zero-knowledge proofs. POST /api/v1/claims/verify keeps working if you prefer to ask INFRA.

Prefer a library? Any SD-JWT VC implementation works, for example @sd-jwt/sd-jwt-vc from the OpenWallet Foundation, as long as you still check aud and environment.

This page as Markdown: /credentials.md · All docs in one file: /llms-full.txt