Digital credentials on PingFederate

PingFederate credential rails

What PingFederate does when it issues and verifies verifiable credentials, and which keys and certificates it holds, signs with, publishes and trusts. Drawn from the idp-pf-vcs platform as deployed on production.

Back to the explainer
PingFederate 13.1.0 · Jakarta EE 9
SD-JWT VC · ISO/IEC 18013-5 mdoc
OID4VCI 1.0 · OID4VP 1.0 · HAIP
Baseline 3 Sep 2026
Inside the PF JVM
53 WARs + 2 plugins
Issuer, verifier and trust services as WARs. Wallet login and RAR validation as SDK plugins. All live.
Signing keypairs PF holds
3+ 1 JWKS kid
SD-JWT issuer, mdoc Document Signer, reader leaf as PF keystore objects. Status lists sign with a kid in PF's own JWKS.
Anchors PF publishes
3
IACA root, reader root and the OAuth JWKS. Wallets and verifiers pin these, nothing else.
Credential formats
2
SD-JWT VC (JOSE) and ISO 18013-5 mdoc (CBOR / COSE). Both issued and verified in Java, cross-checked against the TypeScript oracle.
Open key gap
1G-31
Status-list signer is nominated and kid-enforced but not purpose-separated from the access-token signer.

Signing map

Left: what PF signs. Centre: the key material PF holds. Right: what PF verifies. Select a key to see where it is used.

PF signs outputs
SD-JWT VC pf-oid4vci
Issuer JWS over the salted-hash disclosures. Holder key bound as cnf.
sdjwt-issuer-1
mdoc MSO / IssuerAuth pf-oid4vci
COSE_Sign1 over the Mobile Security Object. Document Signer leaf chains to the IACA.
mdoc-ds-1IACA (chain)
OID4VP request object pf-oid4vp
JAR, typ=oauth-authz-req+jwt, ES256, leaf-only x5c, client_id = x509_hash:…, DCQL query.
reader-leaf-1reader root (chain)
Token Status List pf-dts
statuslist+jwt, ES256, LSB-first zlib bit array. sub is the list URI.
PF JWKS kid
OAuth access token PF native
JWT from the jwtatm manager, carrying openid_credential authorization_details.
jwtatm signer
Response encryption key pf-oid4vp
Ephemeral P-256, one per request, advertised in client_metadata. Lives minutes, in session.
ephemeral

PingFederate holds

services/pingfederate · Terraform
DSIG keystoreKeyAccessor.getDsigKeypair(id)
Imported as PKCS12 via pingfederate_keypairs_signing_key. Private key crosses the wire once, at import. Rotation is a terraform apply.
OAuth JWKS/pf/JWKS · JwksEndpointKeyAccessor
PF's published signing set. Verified against by wallets, both verifier ports, SCIM, and by the issuer WAR in-process.
Trusted CAsTrustedCAAccessor · pingfederate_certificate_ca
Offline-minted roots PF holds and republishes. PF never generates a certificate-bearing identity.
Session statePF volume · vc-data/
Ephemeral response-encryption keys, c_nonces, VP sessions, offers, status-list bits and next index.
PF verifies inputs
Its own access token pf-oid4vci /credential
In-process, no JWKS fetch. Client allowlist, then the RAR gate: the requested credential_configuration_id must be in authorization_details.
jwtatm signer
Holder key proof pf-oid4vci /credential
Proof JWT over a live c_nonce. The proven key becomes cnf or the mdoc device key.
wallet's key
SD-JWT VC presentation pf-oid4vp /responses
Decrypt the direct_post.jwt JWE, verify the issuer JWS via jwt-vc-issuer, disclosures against _sd, KB-JWT over aud, nonce, sd_hash.
ephemeralissuer key (via jwt-vc-issuer)holder key
mdoc presentation pf-oid4vp /responses
Document Signer chains to a trusted IACA, MSO digests match, device signature verifies over the OID4VP §B.2.6.1 SessionTranscript.
ephemeralmdoc-iaca-1device key
Revocation status pf-oid4vp · wallets
Status list checked by kid against PF's JWKS. A kid absent from the set is refused by name.
PF JWKS kid
Service tokens at SCIM outside PF
The issuer WAR mints its own client_credentials token; SCIM verifies ES256 against /pf/JWKS and allowlists the client id.
jwtatm signer
DSIG keypair, PF keystore OAuth JWKS key Trusted CA, offline root Dashed chip: not PF's key

What runs inside PingFederate

Each extension is a Maven module under pf-ext/, baked into the PF image. WARs mount at their file name; plugins are discovered through a PF-INF marker. Shared crypto lives in vc-core, which imports no PingFederate type.

Issuance · OID4VCI

Issue

Pre-authorized code and authorization code, both formats, claims sourced from the SCIM identity store.

pf-oid4vci.warLive/pf-oid4vci/*
  • /.well-known/openid-credential-issuerNames PF's /as/authorization.oauth2 for auth-code and the WAR's own mini-AS for pre-authorized_code.
  • /.well-known/jwt-vc-issuerPublishes the SD-JWT issuer key verifiers resolve.
  • /nonceIssues the c_nonce the holder's key proof must sign.
  • /tokenPre-authorized code only. Auth-code tokens come from PF itself.
  • /credentialThe issuing step. See below.
  • /admin/offersMints a pre-authorized offer. Bearer-protected; the connector calls it.
  • /admin/healthNames which keys resolved from PF and which are still in the environment.
  1. Verifies the PF access token in-process against PF's own signing set. No introspection, no shared secret.
  2. Gates on client id (PF_OID4VCI_CLIENTS) and enforces the RAR: a token authorising pid-sd-jwt that asks for mdl-mdoc gets 403.
  3. Verifies the holder's key proof over a live c_nonce.
  4. Sources claims from SCIM with its own client_credentials token. An mDL needs a driver-licence document on file; missing evidence is insufficient_evidence, never invented.
  5. Signs the SD-JWT VC or the mdoc MSO and allocates a status-list index.
Signs with sdjwt-issuer-1mdoc-ds-1 · verifies with jwtatm signer
pf.plugins.pf-vc-rar.jarLiveAuthorizationDetailProcessor
  • vcrarProcessor instance registered for the openid_credential authorization-detail type.
  • vci-svcPF client allowed to request that RAR type (authorization_detail_types).
  1. Validates openid_credential entries at authorization time, so the grant carries typed authorization_details the credential endpoint reads off the token.
Presentation · OID4VP

Verify

Signed request out, encrypted response in, and a wallet presentation as a first-class PF login.

pf-oid4vp.warLive/pf-oid4vp/*
  • POST /sessionsCreates a verification session. ?credential=mdl asks for an mDL; default is the PID SD-JWT VC.
  • GET /request/{id}The signed request object the wallet fetches and checks before showing consent.
  • POST /responsesReceives the wallet's direct_post.jwt JWE.
  • GET /sessions/{id}Poll for verified and the disclosed claims.
  1. Builds the request: ES256 by the reader leaf, leaf-only x5c (the anchor is never shipped), x509_hash client id, a fresh encryption key, a DCQL query for PID or mDL.
  2. Decrypts the response with the session's ephemeral key, resolved by kid.
  3. Verifies SD-JWT VC: issuer JWS, disclosures, KB-JWT bound to aud, nonce, sd_hash.
  4. Verifies mdoc: DS chain to a trusted IACA, MSO digests, device signature over the SessionTranscript. Untrusted IACA is refused with a chain error.
Signs with reader-leaf-1 · trusts mdoc-iaca-1PF JWKS kid
pf.plugins.pf-vp-adapter.jarLiveIdpAuthenticationAdapterV2
  • vpWalletLoginAdapter instance, credential pid. Root of the SignInWithWallet policy tree.
  • contractgiven_name, family_name, birthdate, document_number, credential_format.
  1. Turns a wallet presentation into a PF authentication source. Drives a pf-oid4vp session over HTTP at PF's own base URL (plugin and WAR are separate classloaders).
  2. Replaced the standalone verifier's OIDC OP facade, deleted 2 Aug 2026. No IdP connection, no second signing key.
Digital Trust Services

Trust

The material both sides pin. Served by PF, signed with PF's own published key.

pf-dts.warLive/pf-dts/*
  • /statuslists/1Token Status List JWT, ES256, verifiable at /pf/JWKS. Allocate an index at issuance; flip a bit to revoke.
  • /pki/iaca.pemThe IACA anchor issued mdocs chain to.
  • /pki/reader-root.pemThe anchor wallets use to trust a signed request.
  • /healthBaked git commit and status_list_signer: pinned or unpinned-first-p256.
  1. Signs status lists with a nominated kid from PF's published JWKS. If the configured kid is absent from the set, the servlet refuses rather than picking another.
  2. Keeps state on the PF volume: bit array plus next index, atomic writes.
Signs with PF JWKS kid · publishes mdoc-iaca-1reader root
PingFederate nativeBuilt inOAuth AS
  • /as/authorization.oauth2Auth-code issuance and the wallet-login policy.
  • /as/token.oauth2Access tokens for wallet, issuer, verifier, portal and console clients.
  • /pf/JWKSThe published signing set everything above verifies against.
  • jwtatmJWT access token manager. Carries client_id and authorization_details; sets no aud, hence the client-id gate.

Key and certificate ledger

Every piece of key material PF signs with, publishes or trusts. "Resolves from" is the preferred path; the environment variables remain as the legacy path until the migration is verified at /pf-oid4vci/admin/health.

Key / certificate Type Resolves from Signs Verified against by Minted where Rotation
SD-JWT issuer keysdjwt-issuer-1 EC P-256, ES256 PF keystore
PF_SDJWT_KEYPAIR_ID
legacy ISSUER_SIGNING_JWK
SD-JWT VC issuer JWS Wallets and verifiers via /pf-oid4vci/.well-known/jwt-vc-issuer Offline, wrapped as PKCS12, imported by Terraform terraform apply with new file data; id changes, WAR restarts
mdoc Document Signermdoc-ds-1 P-256 leaf certificate, signed by the IACA (18013-5 Annex B) PF keystore
PF_MDOC_DS_KEYPAIR_ID
legacy MDOC_DS_JWK + MDOC_DS_PEM
MSO / IssuerAuth (COSE_Sign1) Verifiers chain DS to the IACA; the wallet's trusted_authorities filter matches by AKI Offline ceremony; the IACA signs the leaf. PF never self-signs it Re-sign offline, re-import
IACA rootmdoc-iaca-1 X.509 CA, offline PF trusted CA
pingfederate_certificate_ca
legacy MDOC_IACA_PEM for the verifier
Nothing at runtime (offline) pf-oid4vp and the iOS reader trust mdocs whose DS chains here; published at /pf-dts/pki/iaca.pem Offline (scripts/pki.mjs); carries SKI/AKI New root means re-issue; old URIs stay proxied until re-issue
Reader authentication leafreader-leaf-1 P-256 leaf certificate, signed by the reader root PF keystore
PF_READER_KEYPAIR_ID
legacy READER_LEAF_JWK + READER_LEAF_PEM
OID4VP request object (JAR), leaf-only x5c. Its SHA-256 is the x509_hash client id Wallets, against the pinned reader root Offline; same identity as the standalone verifier so one pin covers both Changes the verifier's client id; wallets re-pin nothing (root unchanged)
Reader rootreader-root.pem X.509 CA, offline env READER_ROOT_PEM, republished by pf-dts Nothing at runtime Wallets pin it from /pf-dts/pki/reader-root.pem Offline Wallet re-pin
Status-list signerPF_STATUS_LIST_KID P-256 kid in PF's OAuth JWKS PF JWKS
JwksEndpointKeyAccessor; unpinned falls to first P-256 and says so at /pf-dts/health
statuslist+jwt at /pf-dts/statuslists/1 Wallets and both verifier ports, by kid; a kid outside the set is refused PF's own key management PF rotation. G-31 open no purpose separation from the access-token signer
Access-token signerjwtatm ES256, PF OAuth JWKS PF JWKS
Access Token Manager jwtatm
JWT access tokens, incl. openid_credential authorization_details pf-oid4vci in-process (no fetch); SCIM over /pf/JWKS with the Go stdlib PF native PF native
Response encryption keyephemeral P-256, ECDH-ES + A128GCM / A256GCM PF session
per request, resolved by kid to session
Nothing; it decrypts the wallet's direct_post.jwt The wallet encrypts to it from client_metadata In the WAR, per session Every request
Holder keycnf / deviceKey P-256, the wallet's not PF's
proven at /credential
Key proof over c_nonce; KB-JWT or device signature at presentation pf-oid4vci binds it into the credential; pf-oid4vp verifies it at /responses Wallet (Secure Enclave or software) Wallet's concern
Service client secretsvci-svc · issuer-svc · verifier-svc · portal-svc Client secret (SECRET auth) PF OAuth clients
Terraform variables, sensitive
Nothing; they obtain client_credentials tokens PF at /as/token.oauth2; SCIM allowlists the resulting client_id Terraform Rotate the variable, apply
Rules the code enforces

How PF is allowed to use a key

  • A configured id that does not resolve is a refusal. No fallback to the environment, no first-in-set. A mistyped PF_SDJWT_KEYPAIR_ID stops the WAR rather than signing with something nobody chose.
  • A missing signing key is a refusal too. The old behaviour generated a key at startup and minted credentials chaining to nothing.
  • PF holds and uses; it never generates a certificate-bearing identity. The DS and reader leaf must be signed by the offline IACA and reader root. A PF self-signed cert chains to nothing a wallet trusts.
  • The trust anchor is never shipped. Request objects carry a leaf-only x5c (HAIP §5); wallets already hold the root.
  • 5xx bodies never carry key detail. /health serves a coarse token (key_sources: mixed); the full picture sits behind the bearer at /admin/health, because naming a straggler key is target selection.
  • Every signed byte is anchored externally. Java output is checked against ISO 18013-5 Annex D vectors, OID4VP vectors, the EUDI PID rulebook and the TypeScript oracle, never against itself.
Sign in with your wallet

A presentation as a PF login

  1. The relying party sends the person to PF at /as/authorization.oauth2. The SignInWithWallet policy tree starts at vpWalletLogin.
  2. The adapter creates a pf-oid4vp session and commits an HTML page with the QR or same-device link. PF parks the transaction until the browser returns to the resume path.
  3. The wallet fetches the request, checks the reader leaf against its pinned root, shows consent, and posts an encrypted response.
  4. PF verifies the credential in Java: issuer signature, holder binding, revocation. The session flips to verified.
  5. The adapter returns SUCCESS with the disclosed claims mapped onto the WalletIdentity policy contract. PF issues a real authorization code; the ID token carries the credential's claims.

Proven end to end on production on 2 Aug 2026 with an issuer-signed PID SD-JWT VC. The connector demo uses this same path as its "Connector AS".

Read before claiming "done"

Still open

  • Revocation-signer purpose separation. Credentials sign with a DSIG keypair, status lists with an OAuth-JWKS kid. Any key in that JWKS, naming its own kid honestly, can still mint a status list both verifier ports believe. Closing it needs either the draft's id-kp-oauthStatusSigning EKU (OID still TBD) or realigning PF's two signing paths.GAP-REGISTER G-31
  • Where each key resolves from on production is a readout, not a given. The Terraform key objects are gated off until PKCS12 paths are supplied. Treat /pf-oid4vci/admin/health as the truth; key_sources: mixed means environment stragglers remain.docs/KEY-MIGRATION.md
  • The DC API and ISO 18013-7 paths are not in PF. They run only in the standalone TypeScript verifier. For the browser-native connector demo this is the path that matters.docs/ARCHITECTURE.md
  • No mdoc status list yet. 18013-5 §12.3.6 wants a CWT envelope over the same bit array; only the JWT list is published.GAP-REGISTER G-15
  • The TypeScript services still take private keys from environment variables. They refuse to invent keys, but they are outside the PF migration's view.docs/KEY-MIGRATION.md
  • PF-native provisioning in the connector demo is dormant. The connector can mint offers at pf-oid4vci, but it stays behind PF_PROVISIONING until the verifier trusts both IACAs. PF-issued mdocs would fail verification there today.idp-dcapi-demo site/src/index.ts
Where the live state is read from

Verify, don't trust the deploy

  • GET /pf-dts/healthBaked commit matches the build; status_list_signer reads pinned.
  • GET /pf-oid4vp/healthBaked commit; reader identity loaded.
  • GET /pf-oid4vci/admin/healthBearer-protected. Names every key that resolved from PF and every straggler still in the environment, with a scope line.
  • GET /pf-dts/statuslists/1Content type statuslist+jwt, verifies against /pf/JWKS.
  • /pf-admin-api/v1/idp/adaptersau.idp.pf.vpadapter.VpAdapter present in the descriptors.
  • /pf-admin-api/v1/oauth/authorizationDetailProcessorsau.idp.pf.rar.RarProcessor present; instance vcrar.
  • startup loga=AVAILABLE for each WAR context. The platform can silently serve last-good.
  • conformance/run.sh479 spec-cited assertions. PASSED WITH GAPS is not a pass; CI makes a skipped toolchain fatal.

In the ConnectID demo the request-signing authority sits with the Trust Controller stand-in, which signs the DCQL request with the relying party's federation key. PF as Connector AS never holds the key that names the RP. That is by design, not a gap.