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.
- 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".
- 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.
- 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).
- 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.