A Kubernetes operator that manages Verifiable Credentials (VCs). It obtains credentials via OID4VCI (OpenID for Verifiable Credential Issuance), stores them as Kubernetes Secrets, and automatically renews them before expiry.
+-----------------------+
| Kubernetes Cluster |
| |
+------------------+ | +----------------+ | +-------------------+
| Cluster Admin |---->| | vc-operator | | | OID4VCI Issuer |
| (kubectl/Helm) | | | (controller) |---|---->| (e.g., Keycloak) |
+------------------+ | +-------+--------+ | +-------------------+
| | |
| | creates/ |
| | updates |
| v |
| +----------------+ | +-------------------+
| | Secrets |<--|---->| Service Pods |
| | (credentials) | | | (consume creds) |
| +----------------+ | +-------------------+
+-----------------------+
How it works:
- A cluster admin creates a
CredentialIssuerresource pointing to an OID4VCI issuer (e.g., Keycloak) and aVerifiableCredentialRequestspecifying the credential type needed. - The operator discovers the issuer's OID4VCI metadata, authenticates using client credentials from a referenced Secret, and obtains a Verifiable Credential.
- The credential is stored in a Kubernetes Secret (or another pluggable backend).
- The operator monitors credential expiry and automatically renews credentials before they expire, ensuring uninterrupted service operation.
- OID4VCI Protocol Support -- Full OpenID for Verifiable Credential Issuance flow including metadata discovery, token acquisition, and credential issuance.
- Automatic Renewal -- Credentials are renewed before expiry based on
configurable
renewBeforeduration. - Pluggable Storage -- Credentials are stored via a
CredentialStoreinterface. Kubernetes Secrets is the default backend; alternative backends (e.g., HashiCorp Vault) can be added. - Credential Rotation Safety -- Previous credential is retained alongside the new one during rotation, giving consuming services a grace period.
- Prometheus Metrics -- Built-in metrics for credentials issued, renewed, errors, and time-to-expiry.
- Holder Identity Binding -- Optionally bind credentials to a specific holder identity via proof-of-possession JWTs. Supports both JWK-based and DID-based binding.
- Keycloak Support -- First-class support for Keycloak as an OID4VCI issuer.
- Go 1.22+ (for building from source)
- Docker 17.03+
- kubectl v1.26+
- Access to a Kubernetes v1.26+ cluster
- An OID4VCI-compatible credential issuer (e.g., Keycloak with VC support)
# Add the Helm chart (or install from local source)
helm install vc-operator ./charts/vc-operator \
--namespace vc-operator-system \
--create-namespace# Install CRDs
make install
# Deploy the operator
make deploy IMG=ghcr.io/wistefan/vc-operator:latest1. Create an authentication Secret with the client credentials for your OID4VCI issuer:
apiVersion: v1
kind: Secret
metadata:
name: keycloak-vc-credentials
type: Opaque
stringData:
client_id: "my-vc-client"
client_secret: "my-client-secret"2. Create a CredentialIssuer pointing to your OID4VCI issuer:
apiVersion: vc.vc-operator.io/v1alpha1
kind: CredentialIssuer
metadata:
name: my-keycloak-issuer
spec:
issuerURL: "https://keycloak.example.com/realms/vc-issuer"
issuerType: keycloak
authSecretRef:
name: keycloak-vc-credentials3. Create a VerifiableCredentialRequest to obtain a credential:
apiVersion: vc.vc-operator.io/v1alpha1
kind: VerifiableCredentialRequest
metadata:
name: my-service-credential
spec:
issuerRef:
name: my-keycloak-issuer
credentialType: "VerifiableCredential"
format: jwt_vc_json
targetSecretRef:
name: my-service-vc
key: credential
renewBefore: 10m4. Verify the credential was obtained:
# Check the VerifiableCredentialRequest status
kubectl get verifiablecredentialrequests
# NAME ISSUER CREDENTIAL TYPE FORMAT READY EXPIRY RENEWALS AGE
# my-service-credential my-keycloak-issuer VerifiableCredential jwt_vc_json True ... 0 1m
# Check the generated Secret
kubectl get secret my-service-vc -o jsonpath='{.data.credential}' | base64 -dBy default, credentials are issued without being bound to a specific holder. To bind a credential to a holder identity, provide a holder key and optionally a DID.
1. Generate an ECDSA P-256 key pair:
openssl ecparam -genkey -name prime256v1 -noout -out holder-key.pem2. Create a Secret with the holder key:
apiVersion: v1
kind: Secret
metadata:
name: my-holder-key
type: Opaque
data:
key.pem: <base64-encoded contents of holder-key.pem>3. Reference the key in the VerifiableCredentialRequest:
apiVersion: vc.vc-operator.io/v1alpha1
kind: VerifiableCredentialRequest
metadata:
name: my-service-credential
spec:
issuerRef:
name: my-keycloak-issuer
credentialType: "VerifiableCredential"
format: jwt_vc_json
targetSecretRef:
name: my-service-vc
key: credential
# Bind to the holder's key (JWK binding)
holderKeyRef:
name: my-holder-key
# Optional: use a DID instead of raw JWK in the proof
# holderDID: "did:key:zDnaerDaTF5BXEavCrfRZEk316dpbLsfPDZ3WJ5hRTPFU2169"When holderKeyRef is set, the operator signs a proof-of-possession JWT with the
holder's private key and includes it in the credential request. The issuer then
binds the issued credential to that key.
If holderDID is also set, the proof JWT uses a kid header with the DID URL
instead of embedding the full JWK public key. This enables DID-based holder
binding. The DID must resolve to the public key corresponding to the private key
in the referenced Secret.
Configures an OID4VCI credential issuer endpoint. Short name: ciss (e.g. kubectl get ciss).
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
spec.issuerURL |
string |
Yes | -- | Base URL of the OID4VCI issuer. Metadata is discovered at {issuerURL}/.well-known/openid-credential-issuer. Must match https?://.* pattern. |
spec.issuerType |
string |
No | generic |
Issuer implementation type. Supported values: generic, keycloak. |
spec.authSecretRef.name |
string |
Yes | -- | Name of a Secret containing authentication credentials (client_id/client_secret or pre_authorized_code). |
spec.tokenURL |
string |
No | -- | Override the token endpoint discovered from issuer metadata. Must match https?://.* pattern. |
Status fields:
| Field | Description |
|---|---|
status.credentialEndpoint |
Discovered credential issuance endpoint. |
status.tokenEndpoint |
Discovered or overridden token endpoint. |
status.supportedCredentialTypes |
List of credential types the issuer supports. |
status.lastMetadataFetchTime |
Timestamp of the last successful metadata fetch. |
status.conditions |
Standard Kubernetes conditions (Ready). |
Declares a credential that a service needs. The operator obtains and renews it
automatically. Short name: vcr (e.g. kubectl get vcr).
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
spec.issuerRef.name |
string |
Yes | -- | Name of a CredentialIssuer in the same namespace. |
spec.credentialType |
string |
Yes | -- | Credential type identifier (as advertised in the issuer's credential_configurations_supported). |
spec.format |
string |
No | jwt_vc_json |
Credential format. Supported: jwt_vc_json, ldp_vc, jwt_vc, vc+sd-jwt. |
spec.storageType |
string |
No | kubernetes |
Storage backend for the credential. Currently only kubernetes is supported. |
spec.targetSecretRef.name |
string |
Yes | -- | Name of the Kubernetes Secret where the credential is stored. |
spec.targetSecretRef.key |
string |
No | credential |
Key within the Secret data map for the credential value. |
spec.renewBefore |
duration |
No | 5m |
How long before credential expiry to attempt renewal (e.g., 5m, 1h). |
spec.additionalClaims |
map[string]string |
No | -- | Extra claims to include in the credential request (issuer-specific). |
spec.holderKeyRef.name |
string |
No | -- | Name of a Secret containing the holder's ECDSA P-256 private key in PEM format (data key key.pem or tls.key). When set, the operator signs a proof-of-possession JWT with this key, binding the credential to the holder. |
spec.holderDID |
string |
No | -- | DID URL to use as the kid header in the proof JWT instead of embedding the full JWK. Requires holderKeyRef to be set. |
Status fields:
| Field | Description |
|---|---|
status.credentialExpiryTime |
When the current credential expires. |
status.lastIssuanceTime |
When the credential was last issued. |
status.lastRenewalTime |
When the credential was last renewed. |
status.nextRenewalTime |
When the next renewal attempt is scheduled. |
status.renewalCount |
Total number of successful renewals. |
status.credentialFormat |
Format of the stored credential. |
status.conditions |
Standard Kubernetes conditions (Ready, CredentialIssued, RenewalScheduled, Error). |
When a credential is obtained, the operator creates a Kubernetes Secret with the following structure:
| Key | Description |
|---|---|
credential (or custom key) |
The Verifiable Credential (e.g., JWT string). |
format |
The credential format (e.g., jwt_vc_json). |
expiryTimestamp |
RFC 3339 timestamp of credential expiry. |
issuedAtTimestamp |
RFC 3339 timestamp of credential issuance. |
previousCredential |
The previous credential (retained during rotation). |
Labels applied:
app.kubernetes.io/managed-by: vc-operatorapp.kubernetes.io/component: credential
Annotations applied:
vc-operator.io/credential-type: <credential-type>vc-operator.io/source-cr: <namespace>/<cr-name>
| Flag | Default | Description |
|---|---|---|
--metrics-bind-address |
0 (disabled) |
Bind address for the metrics endpoint. Set to :8443 for HTTPS or :8080 for HTTP. |
--health-probe-bind-address |
:8081 |
Bind address for the health probe endpoint (/healthz, /readyz). |
--leader-elect |
false |
Enable leader election for high availability. |
--metrics-secure |
true |
Serve metrics over HTTPS. |
--enable-http2 |
false |
Enable HTTP/2 for metrics and webhook servers. |
--metrics-cert-path |
-- | Directory containing TLS certificate for the metrics server. |
--webhook-cert-path |
-- | Directory containing TLS certificate for webhooks. |
See charts/vc-operator/values.yaml for the
full list of configurable values. Key settings:
| Value | Default | Description |
|---|---|---|
replicaCount |
1 |
Number of operator replicas. |
image.repository |
ghcr.io/wistefan/vc-operator |
Container image repository. |
image.tag |
Chart appVersion |
Container image tag. |
leaderElection.enabled |
true |
Enable leader election. |
metrics.bindAddress |
:8443 |
Metrics endpoint bind address. |
metrics.secure |
true |
Serve metrics over HTTPS. |
resources.limits.cpu |
500m |
CPU limit. |
resources.limits.memory |
128Mi |
Memory limit. |
crds.install |
true |
Install CRDs with the Helm chart. |
The operator exposes the following Prometheus metrics:
| Metric | Type | Description |
|---|---|---|
vc_operator_credentials_issued_total |
Counter | Total number of credentials successfully issued. |
vc_operator_credentials_renewed_total |
Counter | Total number of credentials successfully renewed. |
vc_operator_credential_errors_total |
Counter | Total number of credential issuance/renewal errors. |
vc_operator_credential_expiry_seconds |
Gauge | Time in seconds until the credential expires. |
Symptom: The CredentialIssuer resource does not reach Ready=True.
Check issuer connectivity:
kubectl describe credentialissuer <name>Look at the Conditions section for the reason:
| Reason | Cause | Resolution |
|---|---|---|
MetadataFetchFailed |
Cannot reach the issuer URL or metadata endpoint returned an error. | Verify the issuerURL is correct and accessible from the cluster. Check network policies and DNS. |
AuthSecretNotFound |
The referenced authentication Secret does not exist. | Create the Secret with the correct name in the same namespace. |
AuthSecretInvalid |
The Secret is missing required keys. | Ensure the Secret has client_id and client_secret (or pre_authorized_code) keys. |
Symptom: The credential request does not succeed.
kubectl describe verifiablecredentialrequest <name>| Reason | Cause | Resolution |
|---|---|---|
IssuerNotFound |
The referenced CredentialIssuer does not exist. |
Create the CredentialIssuer resource first, or fix the issuerRef.name. |
IssuerNotReady |
The referenced CredentialIssuer is not yet Ready. |
Wait for the issuer to become ready, or check its status for errors. |
TokenRequestFailed |
Failed to obtain an access token from the issuer. | Verify client credentials are correct. Check issuer logs. |
CredentialRequestFailed |
The credential issuance request was rejected. | Verify the credentialType is supported by the issuer. Check the issuer's credential_configurations_supported. |
StorageFailed |
Failed to create or update the target Secret. | Check RBAC permissions. Ensure no conflicting Secret exists with different ownership. |
HolderKeyInvalid |
The holderKeyRef Secret is missing, contains invalid key data, or holderDID is set without holderKeyRef. |
Verify the holder key Secret exists and contains a valid ECDSA P-256 private key under the key.pem or tls.key data key. If using holderDID, ensure holderKeyRef is also set. |
Symptom: The credential expires without being renewed.
- Check
status.nextRenewalTimeon theVerifiableCredentialRequest:kubectl get vcr <name> -o jsonpath='{.status.nextRenewalTime}'
- Check operator logs for renewal errors:
kubectl logs -n vc-operator-system deployment/vc-operator-controller-manager
- Ensure the
renewBeforeduration is less than the credential's lifetime.
kubectl get events --field-selector involvedObject.kind=CredentialIssuer
kubectl get events --field-selector involvedObject.kind=VerifiableCredentialRequestComplete example configurations are available in
config/samples/keycloak-example/. See the
README in that directory for
step-by-step instructions.
Additional examples:
- Basic sample -- Minimal
CredentialIssuerandVerifiableCredentialRequest. - Holder binding -- Request with holder identity binding via key and DID.
- Custom renewal interval -- Request with a 1-hour renewal buffer.
- Additional claims -- Request with extra issuer-specific claims.
See CONTRIBUTING.md for build instructions, testing, and contribution guidelines.
See docs/architecture.md for architecture decisions and design rationale.
Copyright 2026 Seamless Middleware Technologies S.L and/or its affiliates and other contributors as indicated by the @author tags.
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.