Cómo emite credenciales un sistema con OID4VCI
OID4VCI —OpenID for Verifiable Credential Issuance— es el protocolo de emisión de credenciales verificables sobre HTTP, mantenido por la OpenID Foundation. Es el estándar dominante para emisión gov-to-citizen y reutiliza la infraestructura OAuth 2.0 que ya tienen la mayoría de los sistemas modernos.
Este artículo explica el flujo Pre-Authorized Code, la variante apta para emisión masiva donde el ciudadano ya pasó por un trámite previo y solo necesita reclamar su credencial. Para emisión con login en el momento se usa Auth Code, que sigue el mismo modelo OAuth 2.0 clásico.
Cuándo usar Pre-Auth
| Escenario | Flujo recomendado |
|---|---|
| Emisión masiva post-trámite (gov) | Pre-Auth |
| Emisión interactiva con login del titular | Auth Code |
| Emisión presencial con funcionario verificando identidad | Pre-Auth (con tx_code para confirmar presencia) |
| Emisión por terceros (verificador como issuer) | Auth Code |
La idea de Pre-Auth: el emisor ya autenticó al ciudadano fuera de banda (un trámite, una cita presencial). En vez de hacerlo loguearse otra vez, le entrega un código de un solo uso que la wallet intercambia por la credencial.
Componentes del Issuer
Para emitir credenciales con OID4VCI, un sistema necesita cuatro endpoints HTTP:
Metadata pública
/.well-known/openid-credential-issuer — describe qué credenciales puede emitir, en qué formatos, qué grants soporta.
/.well-known/oauth-authorization-server — describe los endpoints OAuth (token, etc.) y métodos de autenticación soportados.
Endpoints operativos
/token — recibe el pre-authorized_code y lo cambia por un access_token.
/credential — recibe el access_token y un proof con el DID del holder; emite la credencial firmada.
El flujo end-to-end
- 1Generación del offer. El sistema del emisor crea un Credential Offer: un JSON que dice qué credencial se va a emitir, a quién (vía el
pre-authorized_code) y opcionalmente con qué PIN de confirmación (tx_code). Lo serializa como deep link u QR. - 2Entrega al ciudadano. El offer llega al ciudadano por email, SMS, o presencialmente en pantalla. El ciudadano escanea/abre con su wallet.
- 3Resolución del offer. La wallet abre el deep link, recupera el JSON del offer, y descubre el endpoint del emisor + el código pre-autorizado.
- 4Intercambio del código por token. La wallet hace
POST /tokencon elpre-authorized_code(y eltx_codesi aplica). Recibe unaccess_token+ unc_nonce(nonce criptográfico para el siguiente paso). - 5Pedido de credencial. La wallet genera un
proof: un JWT firmado con la clave del DID del holder, que incluye elc_nonce. HacePOST /credentialcon elaccess_tokeny elproof. - 6Emisión. El sistema del emisor verifica el proof (que efectivamente viene del DID al que se le va a emitir), firma la credencial, y la devuelve.
- 7Almacenamiento. La wallet recibe la credencial, la valida una última vez (firma del emisor correcta, datos esperados), y la guarda.
Ejemplo de Credential Offer
{
"credential_issuer": "https://salta.gob.ar",
"credential_configuration_ids": ["ConstanciaDomicilio"],
"grants": {
"urn:ietf:params:oauth:grant-type:pre-authorized_code": {
"pre-authorized_code": "adhjhdjajkdkhjhdj",
"tx_code": {
"input_mode": "numeric",
"length": 4,
"description": "Ingresá el PIN que recibiste"
}
}
}
}
Este JSON se serializa como deep link:
openid-credential-offer://?credential_offer_uri=
https://salta.gob.ar/.well-known/credential-offer/abc123
Cuando la wallet abre ese URI, recupera el JSON con el offer y dispara el flujo.
Implementación de referencia (TypeScript)
// Endpoint /token — intercambio código por access_token
async function handleTokenRequest(req: Request) {
const params = await req.formData();
const grant = params.get("grant_type");
if (grant !== "urn:ietf:params:oauth:grant-type:pre-authorized_code") {
return error(400, "unsupported_grant_type");
}
const preAuth = params.get("pre-authorized_code") as string;
const txCode = params.get("tx_code") as string | null;
const session = await sessions.findByPreAuth(preAuth);
if (!session || session.used) return error(400, "invalid_grant");
if (session.requiresTxCode && session.txCode !== txCode) {
return error(400, "invalid_tx_code");
}
const accessToken = randomToken(32);
const cNonce = randomToken(16);
await sessions.bindToken(session.id, accessToken, cNonce);
return Response.json({
access_token: accessToken,
token_type: "Bearer",
expires_in: 300,
c_nonce: cNonce,
c_nonce_expires_in: 300,
});
}
// Endpoint /credential — emite la credencial
import { signSDJWTCredential } from "@sovrahq/agent";
async function handleCredentialRequest(req: Request) {
const accessToken = parseBearer(req.headers);
const session = await sessions.getByToken(accessToken);
if (!session) return error(401);
const { proof } = await req.json();
const holderDid = await verifyDIDBoundJWT(proof.jwt, session.cNonce);
const vc = await signSDJWTCredential({
issuer: "did:web:salta.gob.ar",
subject: holderDid,
type: ["VerifiableCredential", "ConstanciaDomicilio"],
claims: session.preApprovedClaims,
validUntil: oneYearFromNow(),
});
await sessions.markUsed(session.id);
return Response.json({ credential: vc });
}
El campo proof: clave para evitar phishing
El campo proof que la wallet envía al endpoint /credential es crucial. Es un JWT firmado por la clave privada que controla el DID del holder, y contiene el c_nonce que el servidor le pasó en el paso anterior.
Sin este proof, cualquiera podría reclamar la credencial: solo necesitaría el código pre-autorizado. Con el proof, el servidor confirma que quien está reclamando la credencial efectivamente controla el DID al que se va a emitir.
El JWT del proof típicamente lleva:
{
"alg": "EdDSA",
"typ": "openid4vci-proof+jwt",
"kid": "did:quarkid:sovra:EiAB...#key-1"
}
.
{
"aud": "https://salta.gob.ar",
"iat": 1684300000,
"nonce": "abc123xyz"
}
.
<signature>
Errores comunes en implementaciones
Referencias
Relacionados
- W3C Verifiable Credentials Data Model 2.0 — qué formato lleva la credencial emitida
- ¿Cómo se entrega una credencial? Panorama — el panorama no-técnico
- Revocar credenciales sin perder privacidad — cómo revocar lo emitido

