Volver a Explorar
PatrónTécnicoIdentidad y CVIntermedio

Cómo emite credenciales un sistema con OID4VCI

12 minVerificado · 2026-05-17

OID4VCIOpenID 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

EscenarioFlujo recomendado
Emisión masiva post-trámite (gov)Pre-Auth
Emisión interactiva con login del titularAuth Code
Emisión presencial con funcionario verificando identidadPre-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

  1. 1
    Generació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.
  2. 2
    Entrega al ciudadano. El offer llega al ciudadano por email, SMS, o presencialmente en pantalla. El ciudadano escanea/abre con su wallet.
  3. 3
    Resolució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.
  4. 4
    Intercambio del código por token. La wallet hace POST /token con el pre-authorized_code (y el tx_code si aplica). Recibe un access_token + un c_nonce (nonce criptográfico para el siguiente paso).
  5. 5
    Pedido de credencial. La wallet genera un proof: un JWT firmado con la clave del DID del holder, que incluye el c_nonce. Hace POST /credential con el access_token y el proof.
  6. 6
    Emisió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.
  7. 7
    Almacenamiento. 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

Tagsoid4vcioauthissuanceopenidgov