Skip to content

Credential schema

Opt-in credentialSchema (W3C VC JSON Schema) validation for the verification pipeline: fetch the JSON Schema a credential declares and validate the whole credential against it. Both W3C schema types are handled — a raw JsonSchema, and a JsonSchemaCredential (the schema wrapped in its own signed VC, whose proof is verified through the pipeline before its embedded schema is applied). Wired into verify_credential via resolve_credential_schema=.

openvc.schema

openvc.schema — validate a credential against its credentialSchema (W3C VC JSON Schema).

A credential may declare one or more JSON Schemas it claims to conform to via the credentialSchema property (VCDM 2.0 + the Verifiable Credentials JSON Schema Specification). The JsonSchema type points id at a URL that dereferences to a JSON Schema; the whole credential document is then validated against it.

This check is opt-in, not fail-closed by default. Unlike revocation status, schema conformance is a data-shape property, not a security gate: a signature-valid credential whose shape violates its schema is a malformed issue, not a forgery. So the pipeline validates only when the caller supplies a resolve_credential_schema fetch. Set policy.require_schema=True to make a credential that declares a credentialSchema but is verified without a resolver fail (symmetric with require_status). Once you do opt in, every part of the check is fail-closed — an unreachable schema, a resource that is not a valid JSON Schema, an unsupported schema type, or a validation error all raise.

Transport is the caller's concern. resolve_credential_schema takes a URL and returns the parsed JSON Schema resource as a dict; pass :func:openvc.fetch.https_json_fetch for the SSRF-guarded one (the same guard did:web uses). This module never opens a socket. Remote $ref resolution inside a fetched schema is off: the validator is built with an empty referencing.Registry (no retrieve hook), so a remote $ref fails closed as :class:SchemaResolutionError with no network call — whereas jsonschema's default registry would urllib.urlopen an unresolvable remote $ref, an SSRF vector, since the schema body is attacker-influenced. Local (#/…) refs still resolve against the schema document itself.

Security caveat — untrusted schemas can be a ReDoS vector. A fetched schema is attacker-influenced (the issuer names credentialSchema.id). JSON Schema pattern keywords run on Python's backtracking re engine, so a schema carrying a catastrophic-backtracking regex can burn unbounded CPU during validation. There is no clean dependency-light in-process bound (a hard timeout needs a subprocess; a linear-time engine needs re2); a very deep schema is capped only by the recursion limit (surfaced as :class:SchemaResolutionError). Point resolve_credential_schema at schema hosts you trust, or bound the work in the injected fetch. Validation being opt-in limits exposure.

Dependency-light: the JSON Schema processor (jsonschema) lives behind the [schema] extra and is imported lazily. Without it, a credential that requires schema validation raises :class:SchemaBackendUnavailable — the rest of the library keeps working.

Both W3C schema types are validated. A JsonSchema entry dereferences to a raw JSON Schema. A JsonSchemaCredential entry dereferences to the schema wrapped in its own signed Verifiable Credential: its proof is verified through the same pipeline (an injected verify_inner — this module never imports :mod:openvc.verify, so no import cycle) before its verified credentialSubject.jsonSchema is applied to the outer credential. That recursion is bounded: the schema-defining VC's own credentialSchema is not re-fetched (the proof is the trust anchor), so a hostile chain of schema-VCs cannot loop. Standalone :func:validate_credential_schema still raises :class:UnsupportedSchemaType for a JsonSchemaCredential unless a verify_inner is supplied — the pipeline always supplies one, so a declared schema is never silently skipped when you asked for it to be checked.

Spec notes (W3C VC JSON Schema, CR Draft 2025-02-04):

  • The whole credential document is the validation instance (schemas key into credentialSubject at their own top level); we never pre-extract the subject.
  • The dialect follows the schema's $schema (draft 2020-12 is the required one). A resource with no $schema "MUST NOT be processed" — we reject it.
  • format keywords (e.g. email) are treated as annotations, not assertions — JSON Schema's default; we do not pull in format libraries.
  • digestSRI on an entry is enforced: the resolver returns the raw schema bytes, and a sha256-/sha384-/sha512- SRI hash is verified over them (constant-time) before the schema is parsed — a mismatch fails closed. An issuer can thus pin the exact schema so even a compromised schema host cannot swap it.
  • The spec models the outcome as Success / Failure / Indeterminate; this fail-closed verifier collapses Indeterminate (unreachable / non-schema / unsupported) into a raised :class:SchemaError rather than a distinct value.

SchemaError

Bases: OpenvcError

Base class for every credentialSchema validation failure.

Source code in src/openvc/schema.py
113
114
class SchemaError(OpenvcError):
    """Base class for every ``credentialSchema`` validation failure."""

SchemaBackendUnavailable

Bases: SchemaError

Schema validation was required but the jsonschema processor is not installed (pip install openvc-core[schema]).

Source code in src/openvc/schema.py
117
118
119
class SchemaBackendUnavailable(SchemaError):
    """Schema validation was required but the ``jsonschema`` processor is not
    installed (``pip install openvc-core[schema]``)."""

SchemaUnavailable

Bases: SchemaError

The credential declares a credentialSchema but no resolve_credential_schema was supplied and require_schema is set (fail-closed, symmetric with :class:~openvc.verify.StatusUnavailable).

Source code in src/openvc/schema.py
122
123
124
125
class SchemaUnavailable(SchemaError):
    """The credential declares a ``credentialSchema`` but no
    ``resolve_credential_schema`` was supplied and ``require_schema`` is set
    (fail-closed, symmetric with :class:`~openvc.verify.StatusUnavailable`)."""

SchemaResolutionError

Bases: SchemaError

The schema could not be fetched, or the fetched resource is not a usable JSON Schema (bad transport, not JSON, or not a valid schema).

Source code in src/openvc/schema.py
128
129
130
class SchemaResolutionError(SchemaError):
    """The schema could not be fetched, or the fetched resource is not a usable
    JSON Schema (bad transport, not JSON, or not a valid schema)."""

UnsupportedSchemaType

Bases: SchemaError

A declared credentialSchema entry has a type this verifier cannot validate (e.g. JsonSchemaCredential).

Source code in src/openvc/schema.py
133
134
135
class UnsupportedSchemaType(SchemaError):
    """A declared ``credentialSchema`` entry has a type this verifier cannot
    validate (e.g. ``JsonSchemaCredential``)."""

SchemaValidationError

Bases: SchemaError

The credential does not conform to a declared JSON Schema.

Source code in src/openvc/schema.py
138
139
class SchemaValidationError(SchemaError):
    """The credential does not conform to a declared JSON Schema."""

CredentialSchemaRef dataclass

One parsed credentialSchema entry.

Source code in src/openvc/schema.py
146
147
148
149
150
151
@dataclass(frozen=True)
class CredentialSchemaRef:
    """One parsed ``credentialSchema`` entry."""
    id: str                        # URL that dereferences to the schema (resource)
    type: str                      # the recognised schema type, e.g. "JsonSchema"
    digest_sri: str | None = None  # optional subresource-integrity hash (enforced over raw bytes)

SchemaValidationResult dataclass

The outcome of validating a credential against its declared schema(s).

Source code in src/openvc/schema.py
154
155
156
157
158
@dataclass(frozen=True)
class SchemaValidationResult:
    """The outcome of validating a credential against its declared schema(s)."""
    validated: bool                       # True if at least one JsonSchema was applied
    schemas: tuple[str, ...]              # the schema URLs validated against

parse_credential_schemas(credential)

Parse the credential's credentialSchema property into typed refs.

Each entry needs an id (the schema URL) and a type. The recognised type (JsonSchema / JsonSchemaCredential) is kept; an entry whose type is none of those is preserved with its first declared type so the caller can decide (the pipeline treats an unknown type as unsupported when opted in).

Source code in src/openvc/schema.py
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
def parse_credential_schemas(credential: dict[str, Any]) -> list[CredentialSchemaRef]:
    """Parse the credential's ``credentialSchema`` property into typed refs.

    Each entry needs an ``id`` (the schema URL) and a ``type``. The recognised
    type (``JsonSchema`` / ``JsonSchemaCredential``) is kept; an entry whose type
    is none of those is preserved with its first declared type so the caller can
    decide (the pipeline treats an unknown type as unsupported when opted in)."""
    refs: list[CredentialSchemaRef] = []
    for raw in _as_entry_list(credential.get("credentialSchema")):
        sid = raw.get("id")
        stype = raw.get("type")
        if not isinstance(sid, str) or not sid:
            raise SchemaResolutionError("credentialSchema entry needs a string id")
        types = [stype] if isinstance(stype, str) else stype
        if not isinstance(types, list) or not all(isinstance(t, str) for t in types):
            raise SchemaResolutionError("credentialSchema entry needs a type")
        # Prefer a recognised type; otherwise keep the first declared one.
        recognised = next((t for t in types if t in _KNOWN_SCHEMA_TYPES), None)
        chosen = recognised if recognised is not None else (types[0] if types else "")
        if not chosen:
            raise SchemaResolutionError("credentialSchema entry needs a type")
        sri = raw.get("digestSRI")
        if sri is not None and not isinstance(sri, str):
            # A present-but-malformed integrity pin must fail closed, not silently degrade
            # to "no pin" — otherwise a compromised schema host would go unnoticed.
            raise SchemaResolutionError("credentialSchema digestSRI must be a string")
        refs.append(CredentialSchemaRef(id=sid, type=chosen, digest_sri=sri))
    return refs

validate_credential_schema(credential, *, resolve_credential_schema, verify_inner=None)

Validate credential against every schema it declares (JsonSchema and JsonSchemaCredential).

Fetches each declared schema via resolve_credential_schema and validates the whole credential document against it. A JsonSchemaCredential entry dereferences to a signed VC whose proof is verified with verify_inner before its embedded schema is applied; without a verify_inner such an entry raises :class:UnsupportedSchemaType (the :func:openvc.verify.verify_credential pipeline always injects one). Raises :class:SchemaValidationError on a mismatch, :class:SchemaResolutionError if a schema cannot be fetched/verified or is not a valid JSON Schema, and :class:UnsupportedSchemaType for an unrecognised schema type. Returns a :class:SchemaValidationResult recording which schema URLs were applied (which may be empty if the credential declares no credentialSchema).

Source code in src/openvc/schema.py
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
def validate_credential_schema(
    credential: dict[str, Any], *,
    resolve_credential_schema: ResolveCredentialSchema,
    verify_inner: VerifyInnerCredential | None = None,
) -> SchemaValidationResult:
    """Validate *credential* against every schema it declares (``JsonSchema`` and
    ``JsonSchemaCredential``).

    Fetches each declared schema via *resolve_credential_schema* and validates the
    whole credential document against it. A ``JsonSchemaCredential`` entry
    dereferences to a signed VC whose proof is verified with *verify_inner* before
    its embedded schema is applied; without a *verify_inner* such an entry raises
    :class:`UnsupportedSchemaType` (the :func:`openvc.verify.verify_credential`
    pipeline always injects one). Raises :class:`SchemaValidationError` on a
    mismatch, :class:`SchemaResolutionError` if a schema cannot be fetched/verified
    or is not a valid JSON Schema, and :class:`UnsupportedSchemaType` for an
    unrecognised schema type. Returns a :class:`SchemaValidationResult` recording
    which schema URLs were applied (which may be empty if the credential declares
    no ``credentialSchema``)."""
    applied: list[str] = []
    for ref in parse_credential_schemas(credential):
        schema = _resolve_schema(ref, resolve_credential_schema, verify_inner)
        _validate_instance(credential, schema, ref.id)
        applied.append(ref.id)

    return SchemaValidationResult(validated=bool(applied), schemas=tuple(applied))

validate_credential_schema_async(credential, *, resolve_credential_schema, verify_inner=None) async

Async :func:validate_credential_schema — awaits an async resolve_credential_schema (and, for a JsonSchemaCredential, an async verify_inner). Identical validation and fail-closed semantics; the JSON-Schema validation itself is the same sync CPU code.

Source code in src/openvc/schema.py
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
async def validate_credential_schema_async(
    credential: dict[str, Any], *,
    resolve_credential_schema: AsyncResolveCredentialSchema,
    verify_inner: AsyncVerifyInnerCredential | None = None,
) -> SchemaValidationResult:
    """Async :func:`validate_credential_schema` — awaits an async
    ``resolve_credential_schema`` (and, for a ``JsonSchemaCredential``, an async
    ``verify_inner``). Identical validation and fail-closed semantics; the
    JSON-Schema validation itself is the same sync CPU code."""
    applied: list[str] = []
    for ref in parse_credential_schemas(credential):
        schema = await _resolve_schema_async(ref, resolve_credential_schema, verify_inner)
        _validate_instance(credential, schema, ref.id)
        applied.append(ref.id)
    return SchemaValidationResult(validated=bool(applied), schemas=tuple(applied))

SD-JWT VC Type Metadata

Resolve the Type Metadata a credential's vct points to, pin it with vct#integrity (W3C SRI), walk the extends chain, and validate the disclosed claims against the type's claims metadata. Opt-in fetch via openvc.resolvers.default_type_metadata_resolver.

openvc.type_metadata

openvc.type_metadata — SD-JWT VC Type Metadata (draft-ietf-oauth-sd-jwt-vc-17 §4).

Verifier side: resolve the Type Metadata a credential's vct points to, pin it with vct#integrity (a W3C Subresource-Integrity hash), enforce metadata.vct equals the credential's vct, walk the extends chain (parent before child, each integrity-pinned, cycle-/depth-bounded), compose the inherited claim metadata, and validate the processed SD-JWT payload against it — the DCQL-style path engine plus mandatory. Fetch is opt-in and every failure is fail-closed (§4.7: "If claim metadata processing or validation fails, the SD-JWT VC MUST be rejected").

Two scope notes tied to the current draft:

  • Embedded JSON Schema was removed from Type Metadata in draft-12 — there is no schema / schema_uri member and no schema dialect. Payload validation is done through the claims array, not a JSON Schema.
  • The per-claim sd (selective-disclosure) constraint is an issuance-time rule; a verifier can only check it against per-claim disclosure provenance, which the SD-JWT layer does not currently expose, so it is not enforced here (path structure and mandatory presence are). This is a documented boundary, not a silent skip.

Reuses :func:openvc.schema._verify_sri (the reviewed, constant-time SRI check) and, via the caller-supplied resolver, :func:openvc.fetch.https_bytes_fetch — so the fetch inherits the SSRF / size / time guards. Type Metadata URLs are as untrusted as did:web; never resolve them through the EBSI client.

TypeMetadataError

Bases: OpenvcError

Base class for SD-JWT VC Type Metadata failures.

Source code in src/openvc/type_metadata.py
58
59
class TypeMetadataError(OpenvcError):
    """Base class for SD-JWT VC Type Metadata failures."""

TypeMetadataResolutionError

Bases: TypeMetadataError

The Type Metadata could not be fetched, parsed, integrity-checked, or the extends chain cycles / is too deep.

Source code in src/openvc/type_metadata.py
62
63
64
class TypeMetadataResolutionError(TypeMetadataError):
    """The Type Metadata could not be fetched, parsed, integrity-checked, or the
    ``extends`` chain cycles / is too deep."""

TypeMetadataMismatch

Bases: TypeMetadataError

A resolved document's vct differs from the credential's vct.

Source code in src/openvc/type_metadata.py
67
68
class TypeMetadataMismatch(TypeMetadataError):
    """A resolved document's ``vct`` differs from the credential's ``vct``."""

TypeMetadataClaimsInvalid

Bases: TypeMetadataError

The processed SD-JWT payload does not satisfy the composed claim metadata.

Source code in src/openvc/type_metadata.py
71
72
class TypeMetadataClaimsInvalid(TypeMetadataError):
    """The processed SD-JWT payload does not satisfy the composed claim metadata."""

TypeMetadataResult dataclass

The outcome of Type Metadata processing for a verified SD-JWT VC.

Source code in src/openvc/type_metadata.py
75
76
77
78
79
80
@dataclass(frozen=True)
class TypeMetadataResult:
    """The outcome of Type Metadata processing for a verified SD-JWT VC."""
    vct: str
    documents: tuple[dict[str, Any], ...]     # the resolved chain, subtype first
    claims: tuple[dict[str, Any], ...]        # the composed claim-metadata objects

validate_type_metadata(payload, *, vct, vct_integrity=None, resolve, max_extends_depth=_MAX_EXTENDS_DEPTH)

Resolve and enforce the Type Metadata for a verified SD-JWT VC's payload.

vct / vct_integrity are the credential's vct and vct#integrity claims (from the processed payload). resolve fetches a Type Metadata document by URL (opt-in — pass e.g. :func:openvc.resolvers.default_type_metadata_resolver). Fails closed: an integrity mismatch, a document whose vct differs, an extends cycle / over-depth, or a claim-metadata violation each raise a :class:TypeMetadataError.

Source code in src/openvc/type_metadata.py
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
def validate_type_metadata(
    payload: dict[str, Any],
    *,
    vct: str,
    vct_integrity: str | None = None,
    resolve: ResolveTypeMetadata,
    max_extends_depth: int = _MAX_EXTENDS_DEPTH,
) -> TypeMetadataResult:
    """Resolve and enforce the Type Metadata for a verified SD-JWT VC's *payload*.

    *vct* / *vct_integrity* are the credential's ``vct`` and ``vct#integrity`` claims
    (from the processed payload). *resolve* fetches a Type Metadata document by URL
    (opt-in — pass e.g. :func:`openvc.resolvers.default_type_metadata_resolver`). Fails
    closed: an integrity mismatch, a document whose ``vct`` differs, an ``extends``
    cycle / over-depth, or a claim-metadata violation each raise a
    :class:`TypeMetadataError`.
    """
    documents, claims = _resolve_chain(vct, vct_integrity, resolve, max_extends_depth, [])
    _validate_claims(payload, claims)
    return TypeMetadataResult(vct=vct, documents=tuple(documents), claims=tuple(claims))