Verifiable Credentials
A verifiable credential is a signed, portable claim about a person or an agent that a third party can check without calling you. That is the difference from a token: a token is proof to a party that trusts your authorization server; a credential is proof to anyone who can resolve your DID document.
Credentials here are JWT-secured — VC-JWT and SD-JWT VC — with no JSON-LD canonicalization anywhere.
Signing is EdDSA with the tenant’s own key, and the issuer is the tenant’s
did:web:<slug>.oauth.work.
Formats
Section titled “Formats”| Format | Value | Use it when |
|---|---|---|
| SD-JWT VC | sd-jwt-vc (default) | The holder should disclose some claims and withhold others |
| VC-JWT | vc+jwt | You want a plain W3C VCDM 2.0 credential with no selective disclosure |
Selective disclosure is why sd-jwt-vc is the default. A credential asserting employment, start
date, and department lets the holder present only the part a given verifier needs.
Credential types
Section titled “Credential types”The platform issues three, chosen from the token presented at issuance rather than from a parameter:
| Type | Issued to | Contains |
|---|---|---|
WorkCredential | A user token with work_credential scope | Employment claims about the subject |
AgentIdentityCredential | An agent or service token | Agent id, actor type, org, scope |
DelegationCredential | A delegated token (one carrying act) | The subject, the actor chain, org, scope |
See issuance and wallets for how to request each.
Verification
Section titled “Verification”Anyone can verify, with no credential of their own:
POST /credentials/verifyContent-Type: application/json
{ "credential": "eyJ…~…", "format": "sd-jwt-vc" }{ "valid": true, "format": "sd-jwt-vc", "issuer": "did:web:acme.oauth.work", "subject": "did:key:z6Mk…", "claims": { "employer": "Acme Corp", "role": "Engineer" }, "checks": { "signature": true, "issuerDid": true, "notExpired": true, "notRevoked": true }}Reading checks
Section titled “Reading checks”Each entry is a separate assertion, and valid is not simply their conjunction — read both.
| Check | Means |
|---|---|
signature | The signature verifies against the issuer’s published key. |
issuerDid | The kid resolved to a key in the issuer’s DID document. |
notExpired | Within its validity window. |
notRevoked | The status list bit is clear. |
notRevoked appears only when the credential carries a status entry. A credential issued without an
organization has no status list and therefore no revocation check — its absence means “not
revocable”, not “not revoked”.
A failure reports why:
{ "valid": false, "checks": { "notRevoked": false }, "error": "credential revoked" }{ "valid": false, "checks": { "notRevoked": false }, "error": "revocation status could not be verified (foreign status list)"}The second is the important one. If a credential points at a status list this platform does not host, the result is not valid rather than assumed-good — an unverifiable revocation status is treated as a failure, because the alternative is that anyone can suppress revocation by pointing their credential’s status entry somewhere unreachable.
Verifying it yourself
Section titled “Verifying it yourself”You do not have to call the endpoint. The credential names its issuer DID in the kid header;
resolve the DID document, take the key, and verify:
did:web:acme.oauth.work → https://acme.oauth.work/.well-known/did.jsonimport { importJWK, jwtVerify, decodeProtectedHeader } from 'jose'
const { kid } = decodeProtectedHeader(credential.split('~')[0])const doc = await fetch('https://acme.oauth.work/.well-known/did.json').then((r) => r.json())const method = doc.verificationMethod.find((m) => m.id === kid)const key = await importJWK(method.publicKeyJwk, 'EdDSA')
const { payload } = await jwtVerify(credential.split('~')[0], key, { algorithms: ['EdDSA'] })Then check the status list yourself — see revocation. The DID document lists every key the org has published, including retired ones, so credentials signed before a rotation still verify.
Presentations
Section titled “Presentations”A holder presenting a credential with proof of possession — rather than a bearer copy — uses the presentation endpoint:
POST /credentials/presentations/verifyBind a credential to a holder at issuance with holderDid (a did:key) and only that holder can
present it as their own.
Lifetime
Section titled “Lifetime”Credentials are valid for 90 days by default. Because that is far longer than an access token, retired signing keys stay published and revocation is the mechanism for cutting one short.
Where credentials fit
Section titled “Where credentials fit”| Situation | Use |
|---|---|
| Your own API authorizing your own users | An access token |
| Proving something to a party that does not trust your issuer | A credential |
| An agent proving what it is, to a third party | AgentIdentityCredential |
| Proving an agent may act for a user | DelegationCredential |