Verifiable credentials
Issue SD-JWT VC and W3C credentials over OpenID4VCI, revoke them with status lists and verify OpenID4VP presentations, signed by your own keys.
better-supabase/verifiable-credentials issues credentials to a wallet and
verifies what a wallet presents back. It speaks SD-JWT VC and the W3C
Verifiable Credentials 2.0 data model (VC-JOSE vc+jwt), issues them through
the OpenID4VCI 1.0 pre-authorized code flow, revokes them with Token Status
List and Bitstring Status List, and checks OpenID4VP 1.0 presentation
responses. It has no tables and no runtime dependency: the crypto is WebCrypto
and the keys stay yours. It runs on every WinterTC runtime. The versions are
pinned in SPEC_PINS.openid4vci, SPEC_PINS.openid4vp, SPEC_PINS.sdJwt,
SPEC_PINS.sdJwtVc, SPEC_PINS.tokenStatusList, SPEC_PINS.vcDataModel and
SPEC_PINS.bitstringStatusList.
import {
createCredentialIssuer,
memoryIssuanceStore,
qualificationSource,
signerFromSigningKeys,
toResponse,
} from "better-supabase/verifiable-credentials";Sign with the signing-keys block
signerFromSigningKeys(signer) adapts the Signer of the
signing-keys block to a CredentialSigner. The
credentials are signed in the database-held key's algorithm and carry its kid,
so a verifier finds the key in your JWKS. If the key rotates while a signer is
alive, signing fails with CREDENTIAL_KEY_ROTATED instead of issuing a token
under a stale kid. signerFromJwk(privateJwk, kid) signs with a key you hold
yourself.
Issue over OpenID4VCI
A source says what a credential contains for a subject. qualificationSource,
courseCompletionSource and passSource read the facts of the
qualifications, courses
and passes blocks through a lookup function you write,
and defineCredentialSource builds one for anything else. Claims a lookup leaves
out are left out of the credential, not issued empty.
const issuer = createCredentialIssuer({
issuer: "https://issuer.example",
signer: await signerFromSigningKeys(signingKeys),
sources: [
qualificationSource({
vct: "https://issuer.example/credentials/qualification",
validFor: Temporal.Duration.from({ hours: 24 * 365 }),
lookup: async (subject) => loadQualification(subject),
}),
],
store: memoryIssuanceStore(() => "https://issuer.example/status/1"),
endpoints: {
token: "https://issuer.example/token",
credential: "https://issuer.example/credential",
nonce: "https://issuer.example/nonce",
},
});
const { uri } = await issuer.createOffer({
configurationId: "qualification",
subject: "qualification:q1",
txCode: "4821",
});uri is the openid-credential-offer:// link for a QR code. Send the optional
txCode out of band. The issuer is transport-free: mount token, nonce and
credential on any route and return toResponse(result). Publish
issuer.metadata() at /.well-known/openid-credential-issuer and
issuer.authorizationServerMetadata() at
/.well-known/oauth-authorization-server.
A pre-authorized code works once. The token endpoint burns it before it checks
the transaction code, so a wrong code cannot be guessed against. The credential
endpoint verifies the wallet's openid4vci-proof+jwt (audience, c_nonce, key
in the header) and binds the credential to that key through cnf. When a proof
or the signing fails, the grant is given back so the wallet can retry.
memoryIssuanceStore keeps offers, tokens, nonces and status indexes in
process and is for tests and single-instance demos. Production implements the
IssuanceStore port over your own table, with an atomic redeemOffer for the pre-authorized code.
Credentials without a flow
issueSdJwt and issueJwtVc sign a credential directly. issueSdJwt takes the
claims, the names that become selectively disclosable, the holder's public key
for cnf and an optional status reference. presentSdJwt makes the holder side:
it reveals the disclosures a verifier asked for and adds a key binding JWT over
the audience, nonce and the presented token's hash. verifySdJwt checks the
issuer signature, every disclosure digest, expiry, vct and the key binding.
Revocation
StatusList is a bit array. Set an index and sign the list as a Token Status
List with signStatusListToken (a statuslist+jwt), or as a Bitstring Status
List credential with signBitstringStatusListCredential. A credential points at
its entry through status.status_list (SD-JWT VC) or bitstringEntry (W3C).
readStatus and readBitstringStatus verify a published list and read an index.
A Bitstring Status List has at least 131,072 entries, as the specification
requires, so create it with StatusList.create(131072) or larger and
toEncodedList() refuses a smaller one. Decoding stops at 16 MiB.
Verify OpenID4VP presentations
const { request, params, uri } = createPresentationRequest({
responseUri: "https://verifier.example/present",
credentials: [
{
id: "qualification",
vct: "https://issuer.example/credentials/qualification",
claims: ["qualification"],
},
],
});request is what you store, keyed by state. uri is the openid4vp:// link.
The client identifier is redirect_uri:<responseUri> and the response mode is
direct_post. When the wallet posts back, call
verifyPresentationResponse(response, { request, issuers }), where issuers
returns the JWKS of an issuer or undefined to reject it. It never throws on a
bad response: the outcome is { ok: false, code, message } for a wrong state, a
missing credential, an issuer issuers does not trust, a
missing requested claim, a key binding for another audience or nonce, or (when
you pass fetchStatusList) a revoked credential.
Passkeys and contact cards
Two smaller additions ship with it. The mfa block registers
passkeys and verifies them for step-up through Supabase Auth's WebAuthn support
(SPEC_PINS.webauthn). The contacts block imports and
exports vCard 4.0 (SPEC_PINS.rfc6350).
Not included
The block that stores offers, status lists and issued credentials in Postgres is
not part of this release; IssuanceStore is the seam for it. Other credential
formats (mdoc, JSON-LD data integrity proofs) and the authorization code flow of
OpenID4VCI are out of scope.
Last updated on
Audit files
Import a SAF-T or XAF audit file into the general ledger and export a fiscal year from it, with a parser that streams and reports what it could not map.
ERP and bookkeeping imports
Move an ERP or bookkeeping system into better-supabase with repeatable import runs, import mode, legal entities, numbering, tax, the ledger, audit files and field packs, and the tests that prove each step.