Persistence
Every in-flight OID4VCI issuance has a corresponding IssuancePipelineSession row. The session carries both operational data (status, timestamps, current phase, completed phases, deferral entries) and sensitive data (the resolved attribute bag, the lookup keys, the approval evidence). The EDK separates these two: operational fields stay queryable in cleartext so an admin UI can list "every session in AWAITING_APPROVAL" without decrypting anything; sensitive fields are encrypted at rest with one of three modes the operator chooses per session.
This page describes the session store contract, the encryption modes and when to use each, and the store implementations the EDK ships. The session lifecycle and what attributes flow through the session are covered in Attribute Pipeline; this page is the persistence layer underneath that.
The Session Store Contract
interface IssuancePipelineSessionStore {
suspend fun create(session: IssuancePipelineSession): IdkResult<IssuancePipelineSession, IdkError>
suspend fun get(sessionId: String): IdkResult<IssuancePipelineSession?, IdkError>
suspend fun getByCorrelationIdHmac(correlationIdHmac: ByteArray): IdkResult<IssuancePipelineSession?, IdkError>
suspend fun update(session: IssuancePipelineSession): IdkResult<IssuancePipelineSession, IdkError>
suspend fun delete(sessionId: String): IdkResult<Boolean, IdkError>
}
There are two lookup paths because the two callers have different keys. The IDK protocol handlers and the EDK pipeline commands know the internal sessionId and use get. External ingress (the REST endpoints under /oid4vci/sessions/{correlationId}/..., the async-source callback) only knows the correlationId, and uses getByCorrelationIdHmac.
The store never sees the raw correlationId. Callers compute an HMAC over the correlation id using a tenant-scoped key and pass the digest; the store indexes by digest. The reason is privacy: a database dump should not let anyone derive correlation ids by correlating with external systems that might leak them. The HMAC computation is shared infrastructure and you do not implement it per call.
create and update are responsible for encrypting the sensitive payload before writing per the session's encryptionMode. get and getByCorrelationIdHmac decrypt before returning. The store is the at-rest encryption boundary; nothing above it sees ciphertext.
What Is Sensitive vs Operational
The fields on IssuancePipelineSession split into two groups by where they need to live:
Sensitive (encrypted at rest):
bag(the attribute records: every resolved claim value)lookupKeys(theLookupKeySet: email addresses, employee ids, business keys, anything that correlates to a real person)approval(the approver identity, the decision, the reason)
Operational (cleartext):
sessionId,tenantId,issuerPartyId,correlationId(as HMAC)pipelineConfiguration(a registered pipeline id; configuration carries no per-session secrets)status(CREATED,PHASE_EXECUTING,AWAITING_DEFERRED, ...)completedPhases,currentPhaseprotocolContext(small per-flow tokens likeissuer_state)deferralEntries(per-binding deferral metadata: source id, failure reason, timestamps)encryptionModecreatedAt,updatedAt,expiresAt
The operational fields are what queries filter on. The cleartext status column lets an operator query "show me every session in AWAITING_APPROVAL older than an hour" without decrypting one payload. The cleartext expiresAt lets a sweeper find expired sessions to delete without touching the encrypted blob. The HMAC'd correlationId lets callback ingress find the session without ever knowing the raw value.
Encryption Modes
SessionEncryptionMode is a sealed type with three variants. The session carries its own mode, so different sessions in the same store can have different protection levels.
PlaintextMode stores the sensitive payload as plaintext. There is no encryption. Use only for local development and tests, or for genuinely non-PII flows where confidentiality of the bag has no value. Do not run it in production.
PlatformEncryptedMode (the default) AEAD-encrypts the sensitive payload under a per-session data encryption key (DEK), and the DEK is wrapped by the tenant key encryption key (KEK). Decryption requires the tenant KEK, which lives in the EDK KMS. The platform alone can read the payload. This is the right default for most deployments: it gives you encryption at rest, key separation per tenant, and a single recovery path through the KMS.
ClientBoundMode behaves like PlatformEncryptedMode, plus the effective key is HKDF-bound to the session's correlationId. Decryption requires re-presenting the raw correlationId to the cipher. The platform alone cannot read the payload; a separately-stored correlationId is needed too. Use this for flows where the operator wants stronger separation between the at-rest data and the encryption material, for example when the correlationId is held by the wallet or the calling backend and is not stored in the issuer's database.
ClientBoundMode has a fallbackToPlatform: Boolean switch. When false (the default), a session whose correlationId cannot be re-presented is unreadable; when true, the session degrades to PlatformEncryptedMode semantics. Use true when you want defence-in-depth without breaking re-issuance flows where the correlationId may be lost.
The mode is chosen at session init through the REST API's encryptionMode field on POST /oid4vci/sessions (see the REST API docs). The EDK default if you omit the field is PlatformEncryptedMode.
Choosing a Mode
A rough decision:
- Local development, integration tests against fixtures:
PlaintextMode. You will see the bag in the database and that is fine. - Production, default:
PlatformEncryptedMode. Encryption at rest with tenant key separation. Operators can recover sessions because the KMS holds the keys. - Production, the
correlationIdis held outside the issuer's database (a wallet pre-shared secret, a high-assurance backend that never persists it next to the session):ClientBoundModewithfallbackToPlatform = false. The issuer alone cannot read sessions; an attacker who exfiltrates only the issuer database gets nothing useful. Re-issuance flows need to re-present thecorrelationId. - Production where you want the strict separation but cannot guarantee the
correlationIdsurvives:ClientBoundModewithfallbackToPlatform = true. Acceptably-strong default with a working recovery path.
You cannot change a session's encryption mode after creation. If you need to upgrade the protection level of an in-flight session, delete it and re-init.
Store Implementations
The EDK ships one store binding and the IDK ships another:
KvIssuancePipelineSessionStore (default in lib-credential-issuance-pipeline-impl) is backed by the IDK KeyValueStore abstraction. The KV store implementation you wire (in-memory, SQLite, Redis) determines the actual persistence. Suitable for development, single-process deployments, and small-scale production where a real database would be over-engineering.
Postgres-backed store (VDX-supplied) is a Postgres implementation that exposes IssuancePipelineSession rows in a relational layout: top-level columns for the operational fields, a single BYTEA column for the encrypted sensitive blob, indexed by session_id and correlationId_hmac. Use this when you want SQL-level queries against the operational fields, when you need transactional semantics with adjacent data (the issuer-core session, audit rows), or when you want backup/restore through your existing database tooling.
In an EDK-only deployment without VDX modules, you get the KV-backed store automatically. To use the Postgres store in an EDK deployment that has the VDX module on the classpath, the Metro binding for IssuancePipelineSessionStore resolves to the Postgres implementation through replaces, and no code changes are needed in your service.
TTL and Cleanup
Every session has an expiresAt. The default TTL is set by the EDK config (issuance.pipeline.session.ttl-seconds, configurable per tenant); the REST Init call can override it per session via the ttlSeconds field. Expired sessions move to status EXPIRED on the next access; they remain in the store until a sweeper removes them.
The sweeper is a separate concern. The EDK does not run one inside the request path. Wire a scheduled job that calls IssuancePipelineSessionStore.delete for every session past expiresAt plus a grace window. Tune the grace window to your audit requirements: once a session is deleted, the encrypted payload is gone, and so is the audit trail of its contents (the standard EDK audit log still has the command-level history).
What Goes Where, Concretely
When the operator looks at the database after one issuance flow has run, they see:
- One
issuance_pipeline_sessionrow withstatus = COMPLETED,correlation_id_hmac = <bytes>,created_at/updated_at/expires_atpopulated,completed_phaseslisting every phase that ran, and an encryptedsensitive_payloadblob. - The decrypted payload, if they could read it, would carry every
AttributeRecord(path, value, source, phase, timestamp, priority), everyLookupKey, and theApprovalStateif approval was used. - The issuer-core
issuance_sessionrow (separate table, owned by the IDK issuer module) with the protocol-level state and a matchingcorrelation_id. - Audit events for every command (
issuance.pipeline.init-session,issuance.pipeline.contribute-attributes,oid4vci.protocol.handle-credential, ...) in the standard EDK audit log.
The pipeline session and the issuer-core session live in separate tables. The join key is the correlationId (raw on the issuer-core side, HMAC'd on the pipeline side). They do not share a single transaction; the EDK does not need them to, because the pipeline session is the source of truth for attribute state and the issuer-core session is the source of truth for protocol state.
Multi-Tenant Routing
The session store runs under the standard EDK database routing layer in deployments that need per-tenant databases or schemas. The store does not have its own routing; it uses whatever the platform routes for the calling tenant. The tenant id is on the session row, so even when one database is shared across tenants, queries are tenant-scoped at the SQL level.
Recovery and Re-Hydration
If a session is in AWAITING_DEFERRED or AWAITING_APPROVAL and the issuer service restarts, nothing special happens: the persistent state in the store is the truth, and the next protocol event (a wallet poll, an approval submission, a callback arrival) drives the session forward by reading from the store, running a phase, and persisting again. There is no in-memory state that needs to be reconstructed.
ClientBoundMode sessions need the correlationId to be re-presented to decrypt the payload. The protocol handlers and the REST endpoints all take the correlationId in the request, so the recovery path is automatic: when an inbound request arrives carrying the correlationId, the store loads the session, the cipher unwraps the payload using the supplied correlationId, the engine runs, and the updated session is re-encrypted and persisted. The correlationId is never persisted in plaintext on the session row even in ClientBoundMode; only the HMAC is.