Build an issuer
A relying party verifies; an issuer mints credentials in the first place. This guide walks the OID4VCI pre-authorized-code flow end-to-end, producing an SD-JWT-VC the EU reference wallet will accept.
What we'll build
- A
POST /v1/credential-offersroute that takes the subject claims for one user and mints an opaque pre-auth code + a deep link. - The OID4VCI endpoints the wallet calls:
GET /.well-known/openid-credential-issuer— metadataGET /.well-known/oauth-authorization-server— OAuth metadataPOST /oid4vci/token— exchanges pre-auth code for an access tokenPOST /oid4vci/credential— produces the SD-JWT-VC
Install
pnpm add fastify @gramota/issuer @gramota/oid4vci @gramota/jose
Mint the offer
import { randomBytes } from "node:crypto";
import Fastify from "fastify";
import formbody from "@fastify/formbody";
const app = Fastify();
await app.register(formbody);
const ISSUER_URL = "https://issuer.example.com";
interface Offer {
preAuthCode: string;
subject: Record<string, unknown>;
vct: string;
expiresAt: number;
}
const offers = new Map<string, Offer>();
app.post("/v1/credential-offers", async (req) => {
const { subject, vct } = req.body as { subject: Record<string, unknown>; vct: string };
const preAuthCode = randomBytes(24).toString("base64url");
offers.set(preAuthCode, {
preAuthCode,
subject,
vct,
expiresAt: Date.now() + 30 * 60 * 1000,
});
const offerObject = {
credential_issuer: ISSUER_URL,
credential_configuration_ids: [`${vct}_sd_jwt_vc`],
grants: {
"urn:ietf:params:oauth:grant-type:pre-authorized_code": {
"pre-authorized_code": preAuthCode,
},
},
};
const offerUrl = `openid-credential-offer://?credential_offer=${
encodeURIComponent(JSON.stringify(offerObject))
}`;
return { offerUrl, preAuthCode };
});
Publish issuer metadata
import { Issuer } from "@gramota/issuer";
const issuer = new Issuer({
issuerId: ISSUER_URL,
privateKey: ISSUER_PRIVATE_JWK,
publicKey: ISSUER_PUBLIC_JWK,
alg: "ES256",
});
app.get("/.well-known/openid-credential-issuer", async () => ({
credential_issuer: ISSUER_URL,
authorization_servers: [ISSUER_URL],
credential_endpoint: `${ISSUER_URL}/oid4vci/credential`,
// Advertise batch issuance — EU wallet asks for numberOfCredentials = 10
// when this is present. See the batch-issuance guide.
batch_credential_issuance: { batch_size: 50 },
credential_configurations_supported: {
"urn:eudi:pid:1_sd_jwt_vc": {
format: "dc+sd-jwt",
scope: "urn:eudi:pid:1",
cryptographic_binding_methods_supported: ["jwk"],
credential_signing_alg_values_supported: ["ES256"],
proof_types_supported: { jwt: { proof_signing_alg_values_supported: ["ES256"] } },
vct: "urn:eudi:pid:1",
},
},
}));
app.get("/.well-known/oauth-authorization-server", async () => ({
issuer: ISSUER_URL,
token_endpoint: `${ISSUER_URL}/oid4vci/token`,
grant_types_supported: ["urn:ietf:params:oauth:grant-type:pre-authorized_code"],
token_endpoint_auth_methods_supported: ["none"],
dpop_signing_alg_values_supported: ["ES256"],
}));
app.get("/.well-known/jwt-vc-issuer", async () => ({
issuer: ISSUER_URL,
jwks: { keys: [issuer.publicKey] },
}));
Token exchange
import { verifyDpopJwt } from "@gramota/oid4vci";
const dpopJtiSeen = new Set<string>();
app.post("/oid4vci/token", async (req, reply) => {
const body = req.body as Record<string, string>;
const offer = offers.get(body["pre-authorized_code"] ?? "");
if (!offer || Date.now() > offer.expiresAt) {
return reply.code(400).send({
error: "invalid_grant",
error_description: "pre-authorized_code is unknown, expired, or already used",
});
}
offers.delete(offer.preAuthCode);
const dpopHeader = req.headers["dpop"];
let jkt: string | undefined;
if (typeof dpopHeader === "string") {
const { jkt: thumb } = await verifyDpopJwt({
jwt: dpopHeader,
htm: "POST",
htu: `${ISSUER_URL}/oid4vci/token`,
hasSeenJti: (j) => dpopJtiSeen.has(j),
recordJti: (j) => dpopJtiSeen.add(j),
});
jkt = thumb;
}
const accessToken = randomBytes(32).toString("base64url");
tokenStore.set(accessToken, { offer, jkt, expiresAt: Date.now() + 5 * 60_000 });
return {
access_token: accessToken,
token_type: jkt ? "DPoP" : "Bearer",
expires_in: 300,
c_nonce: randomBytes(16).toString("base64url"),
c_nonce_expires_in: 300,
};
});
Issue the credential
import { parseCredentialRequest } from "@gramota/oid4vci";
import { jwtVerify, importJWK, type JWK } from "jose";
app.post("/oid4vci/credential", async (req, reply) => {
// 1. Resolve token + DPoP.
const auth = req.headers.authorization?.replace(/^DPoP /i, "") ?? "";
const t = tokenStore.get(auth);
if (!t || Date.now() > t.expiresAt) {
return reply.code(401).send({ error: "invalid_token" });
}
// 2. Re-verify DPoP if it was used at /token. Pass `accessToken` so
// verifyDpopJwt enforces the `ath` claim against the bearer token.
// The thumbprint check (sender constraint) is up to the caller —
// compare result.jkt to the value we recorded at /token-time.
if (t.jkt) {
const { jkt } = await verifyDpopJwt({
jwt: req.headers.dpop as string,
htm: "POST",
htu: `${ISSUER_URL}/oid4vci/credential`,
accessToken: auth,
hasSeenJti: (j) => dpopJtiSeen.has(j),
recordJti: (j) => dpopJtiSeen.add(j),
});
if (jkt !== t.jkt) {
return reply.code(401).send({ error: "invalid_token" });
}
}
// 3. Parse the request — handles Draft 13 + Draft 15 shapes.
const parsed = parseCredentialRequest({ body: req.body });
// 4. Verify each proof JWT — the wallet attests holder keys here.
const holderKeys: JWK[] = [];
for (const proofJwt of parsed.proofJwts) {
const { protectedHeader } = await jwtVerify(
proofJwt,
async (h) => importJWK(h.jwk as JWK, "ES256"),
{ audience: ISSUER_URL },
);
holderKeys.push(protectedHeader.jwk as JWK);
}
// 5. Issue. Single-credential is just a batch of 1.
const results = await issuer.credentials.issueBatch({
subject: t.offer.subject,
selectivelyDisclosable: Object.keys(t.offer.subject),
vct: t.offer.vct,
expiresIn: 365 * 24 * 3600,
credentials: holderKeys.map((holderKey) => ({ holderKey: holderKey as any })),
});
return reply.send({
format: "dc+sd-jwt",
credentials: results.map(({ token }) => ({ credential: token })),
});
});
Production checklist
- Replace the
Maps with Postgres + Redis (DPoPjtireplay set needs TTL = max DPoP age, ~5 min). - Replace the in-memory
Issuerconfig with KMS-backed signing.Issueraccepts asigner: SignerStrategy in production ({ alg, sign(payload), publicKey }) — wire it to AWS KMS / Vault. - Wire status-list publication so issued credentials can be revoked.
- Rate-limit
/v1/credential-offersper organization — credential minting is your most expensive operation. - Log every issuance keyed by
credentialIdfromIssueResult— this is your audit trail.