-
Notifications
You must be signed in to change notification settings - Fork 1
Signing and Verification
This page explains, end to end, how openbadgeslib signs an Open Badge and how it later proves that signature is genuine. It covers strict Open Badges 2.0 (a JWS assertion, or a hosted assertion) and OB 3.0 (a JWT-VC) — plus the shared "baking" step that hides the signed token inside an SVG or PNG image so a single picture file is the verifiable credential. (The frozen pre-2.0 format uses the same JWS pipeline under -V 1; see Python API OB1.)
For the trust assumptions behind all of this (which key is trusted, why downloaded keys are not), see Security Model. For key types, formats and the exception hierarchy, see Keys and Errors.
Both versions follow the same three-stage pipeline:
- Build a token — a signed string. OB2 builds a compact JWS over a badge assertion; OB3 builds a native VC-JWT whose payload IS a W3C Verifiable Credential (§8.2).
-
Bake it into an image — the token is written into the badge image as an SVG element or a PNG chunk, using the shared
openbadgeslib.bakingmodule. OB2 and OB3 use the exact same on-disk carrier format, so any conformant viewer can pull the token out regardless of which version produced it. - Reverse it on verify — extract the token from the image, then cryptographically check the signature with a trusted public key and report a status.
The signing algorithm is bound to the key type, in both directions: an RSA key produces and verifies RS256, an EC key produces and verifies ES256, and an Ed25519 key produces and verifies EdDSA. The verifier never lets the token header choose the algorithm — see Security Model for why that matters.
The low-level JWS engine in openbadgeslib/_jws/__init__.py is backed by PyJWT's algorithm implementations and registers seven algorithms:
| Key family | Algorithms |
|---|---|
| RSA |
RS256, RS384, RS512
|
| ECC |
ES256, ES384, ES512
|
| Ed25519 | EdDSA |
In practice the signers emit the 256-bit variant — RS256 for RSA, ES256 for ECC (Ed25519 has the single EdDSA variant). The wider RSA/EC family is accepted on verification for interoperability, but every verifier pins the allowed set to the key's own type, so an RSA key can never validate an ES* token and vice versa. There is deliberately no symmetric (HS*) or alg: none entry.
For strict Open Badges 2.0 the unit of trust is a conformant Assertion — a JSON-LD Badge Object signed as a JWS. The work happens in openbadgeslib/ob2/.
OB2Signer.sign() serialises an Assertion dataclass to its JSON-LD form and signs it:
- The header's
algis bound to the key type (RS256/ES256/EdDSA). - The payload is the Assertion document:
@context(https://w3id.org/openbadges/v2),type"Assertion", an IRIid(urn:uuid:…for a SignedBadge, or the hosting URL for a HostedBadge), a hashedrecipient(sha256$…+ salt,hashed: trueas a real boolean),badge, an ISO 8601issuedOn, and averificationobject —{"type": "SignedBadge", "creator": <CryptographicKey IRI>}or{"type": "HostedBadge"}. Optionalexpires(ISO 8601),imageandevidenceare added when present.
The signature is computed over base64url(header) . base64url(payload); sign_into_svg() / sign_into_png() bake the compact JWS into the image.
from datetime import datetime, timezone
from openbadgeslib.ob2 import OB2Signer, Assertion, IdentityObject, Verification
assertion = Assertion(
recipient=IdentityObject.create('recipient@example.org', salt='s4lt3d'),
badge='https://example.com/badge_1/badge.json',
verification=Verification(type='SignedBadge', creator='https://example.com/badge_1/key.json'),
issued_on=datetime(2026, 1, 1, tzinfo=timezone.utc),
)
svg = OB2Signer(privkey_pem, algorithm='RS256').sign_into_svg(assertion, svg_bytes)Verification lives in openbadgeslib/ob2/verifier.py. OB2Verifier.verify() returns the decoded Assertion or raises OB2VerificationError, running:
-
Structure — validates
@context/typeand parses the Assertion, rejecting legacy shapes (a stringhashed, a UnixissuedOn, auidwithout anid). -
Signature / hosted anchor — a SignedBadge JWS is verified against the operator-supplied trusted key, or, when none is given, the key resolved from
verification.creator(aCryptographicKeywhoseowner/publicKeyback-link to the issuer is checked). A HostedBadge is instead fetched from its ownidover HTTPS and scope-checked against the issuer's origin — that retrieval is the trust anchor, and the baked JWS is only non-gating defence-in-depth. -
Expiration — compares
expiresagainst now. -
Revocation (with
check_revocation=True) — walks badge JSON → issuer JSON →revocationList, matching the assertionidinrevokedAssertions. -
Identity — with
expected_recipient, re-hashes the email with the badge salt and compares to the recipient.
from openbadgeslib.ob2 import OB2Verifier
v = OB2Verifier(pubkey_pem=trusted_pub_key)
assertion = v.verify(token, expected_recipient='recipient@example.org', check_revocation=True)The legacy pre-2.0 flow (Signer/Verifier/get_badge_status, with uid, a verify block and Unix timestamps) is unchanged in openbadgeslib/ob1/ and selected with -V 1. See Python API OB2 for the strict object model, Python API OB1 for the legacy one, and CLI Reference for the commands.
For OB3 the unit of trust is a JWT-VC — an OB 3.0 native VC-JWT (§8.2) whose payload is the W3C Verifiable Credential (its members at the top level, not nested under a vc claim). Signing is in openbadgeslib/ob3/signer.py.
OB3Signer.sign() calls credential.to_jwt_payload() and hands it to PyJWT's jwt.encode(...) with the chosen algorithm (default RS256; RSA keys must use RS*, EC keys ES*, Ed25519 EdDSA). validFrom is encoded as the nbf claim, and the JOSE header carries the issuer's public key as a jwk (§8.2.3). The result is a compact header.payload.signature JWT string.
from openbadgeslib.ob3.signer import OB3Signer
signer = OB3Signer(privkey_pem, algorithm='ES256') # ES256 for an EC key
token = signer.sign(credential) # compact JWT-VC string
svg = signer.sign_into_svg(credential, svg_bytes) # or sign_into_png(...)Verification is in openbadgeslib/ob3/verifier.py. OB3Verifier.verify():
-
Reads the header and rejects the token unless its
algis in the set allowed for this key's type (RS*for RSA,ES*for ECC,EdDSAfor Ed25519) — the token cannot pick its own algorithm. -
Decodes with
jwt.decode(...), which checks the signature and expiry (ExpiredSignatureError→OB3VerificationError("Credential has expired")). -
Validates structure — the payload's
typemust includeVerifiableCredentialand eitherOpenBadgeCredentialor its aliasAchievementCredential(a token with neither is flagged as a possible OB2 JWS);@contextmust be the VC 2.0 + OB v3p0 pair; and the registered claimsiss/nbfmust be present, withissequal to the issuer id andsubequal tocredentialSubject.idwhen the subject carries one. -
Optionally binds the recipient — pass
expected_recipient(an email, amailto:URI, or a DID) and verification additionally requirescredentialSubject.idto match; without it, only the signature, expiry and structure are checked.
from openbadgeslib.ob3.verifier import OB3Verifier
v = OB3Verifier(pubkey_pem)
credential = v.verify(token, expected_recipient='recipient@example.org')Every failure raises OB3VerificationError (a subclass of LibOpenBadgesException, so one except catches both OB2 and OB3 errors). See Python API OB3 for the credential model, and OB2 vs OB3 for how the two generations differ.
Both versions share openbadgeslib/baking.py. Keeping one implementation stops the OB2 and OB3 paths from drifting into two slightly different readers. The carrier mechanism is the same; only the element/keyword identifiers differ, because OB 2.0 and OB 3.0 specify different ones (selected via keyword-only args). OB2 uses <openbadges:assertion> / the openbadges iTXt keyword; OB3 uses <openbadges:credential> (namespace https://purl.imsglobal.org/ob/v3p0) / the openbadgecredential keyword.
bake_svg() parses the SVG, appends the version's element to the root <svg>, and stores the token in its verify attribute (plus a namespace declaration and an optional XML comment):
<openbadges:credential xmlns:openbadges="https://purl.imsglobal.org/ob/v3p0" verify="eyJhbGciOi…"/>extract_svg() reverses it: it finds the version's node and returns the verify attribute value, or None if the element is absent. has_svg() reports whether one is already present (so re-signing is refused). XML is parsed with defusedxml to neutralize entity-expansion attacks.
bake_png() inserts an iTXt chunk (keyword openbadges for OB2, openbadgecredential for OB3) just before the trailing IEND chunk, with an optional tEXt comment chunk, and re-serializes the PNG with correct per-chunk CRCs:
iTXt: "openbadgecredential" \0 <comp_flag> <comp_method> <lang> \0 <translated> \0 <token>
extract_png() does not trust a fixed byte offset — it parses the real iTXt structure (keyword, compression flag/method, language tag, translated keyword, then text), so tokens baked by any conformant tool, including compressed ones, are recovered. has_png() reports presence of the chunk.
Because extraction runs on untrusted input before any signature check, compressed iTXt text is inflated through a bounded decompressor capped at 256 KiB (MAX_ITXT_DECOMPRESSED); a crafted zlib bomb raises DecompressionLimitExceeded instead of exhausting memory.
Both the OB2 and OB3 verifiers expose the reverse step directly as extract_token_from_svg() and extract_token_from_png(), which call the baking helpers and turn a missing token into an OB2VerificationError/OB3VerificationError (and a parse failure into ErrorParsingFile). The legacy OB 1.0 path reads the assertion back through its Badge/Assertion objects. Either way the flow is the same: extract the token from the image, then verify the signature (or, for a HostedBadge, fetch the authoritative copy) against a trusted anchor.
See Security Model for the threat model behind the bounded inflate and key-trust rules, and Keys and Errors for the exceptions raised along the way.
openbadgeslib · LGPLv3 (library) / BSD (CLI) · Issues
Getting Started
Concepts
Reference
Guides
Project