---
title: "PKCS#11 HSM"
description: "Learn how to back Enterprise Stack keys with a PKCS#11 token such as SoftHSMv2 or Thales Luna, so private keys never leave the HSM."
stack: "Enterprise Stack (commercial) — version 1.1.1"
stack_version: "1.1.1"
stack_comparison: https://docs.walt.id/community-vs-enterprise.md
canonical_url: https://docs.walt.id/enterprise-stack/services/key-management-service/pkcs11
generated: 2026-10-01
---
# PKCS#11 HSM

This guide shows you how to store and use signing keys on a PKCS#11 token through the Enterprise Stack Key Management
Service. Private keys are created on the token and never leave it. Enterprise stores only the public key plus a
reference to the token object.

The same integration works with any PKCS#11 library. This page covers two common setups end to end under
[Step 2](#step-2-configure-a-pkcs11-profile):

- SoftHSMv2 — a software token for local development and CI
- Thales Luna — a production HSM (network or PCIe)

## What You Can Do

- Generate signing keys on a PKCS#11 token and use them across Enterprise Stack services (credential issuance, DID
  operations, and so on)
- Adopt keys an HSM operator already provisioned under a key ceremony, by alias
- Keep the token PIN out of key records — only a `resource-access` reference is stored

## How It Works

```text
Enterprise  →  crypto2 PKCS#11 provider  →  SunPKCS11 (JCA)  →  vendor .so  →  token
   |                                                                                 |
   +- stores: public key + { libraryPath, slot, alias, pinReference }                +-- holds the private key
```

Two properties of PKCS#11 matter for deployment:

- **The PIN is never stored with a key.** The key record holds only a reference to a `resource-access` entry. The PIN is
  resolved from configuration when it is needed.
- **A token is node-local.** `libraryPath` is a path on the filesystem of the machine running Enterprise. In a
  multi-replica deployment every replica needs the same library and access to the same token.

**Error:**

PKCS#11 cannot run against the hosted Enterprise sandbox. You need a self-hosted Enterprise Stack deployment where the
vendor PKCS#11 library and token are available to the API process. Use your deployment's base URL in place of
`https://{orgID}.enterprise-sandbox.waltid.dev` in the examples below.

## Prerequisites

Before you begin, ensure you have:

- **Enterprise Stack running** — A self-hosted instance with its config directory at `waltid-enterprise-api/config/`.
- **PKCS#11 token and library** — The vendor shared library installed on every host that runs the Enterprise API.
- **Config access** — Permission to edit `_features.conf` and `resource-access.conf`, then restart the API.

---

## Step 1: Enable Resource Access

PKCS#11 profiles are loaded from `resource-access.conf`. That feature is **off by default**.

Add `resource-access` to `enabledFeatures` in
[`_features.conf`](https://docs.walt.id/enterprise-stack/setup/configurations/config-files/features.md):

```hocon [_features.conf]
enabledFeatures = [
    # ... existing entries ...
    resource-access
]
```

Restart the API after changing feature flags. On startup the log should list your profile ids:

```text
ResourceAccessConfiguration loaded at startup: [..., softhsm, ...]
```

If the id is missing, the feature is not enabled or the config file was not picked up.

---

## Step 2: Configure a PKCS#11 Profile

Add a named profile to
[`resource-access.conf`](https://docs.walt.id/enterprise-stack/setup/configurations/config-files/resource-access.md). The `id` is what you pass
as `configRef` when creating the KMS, and as `backend` when generating keys.

| Field | Required | Description |
|---|---|---|
| `_type` | Yes | `"Pkcs11Access"` in `.conf` files (HOCON class name) |
| `id` | Yes | Profile name. Must match the key under `resourceAccess`. |
| `libraryPath` | Yes | Absolute path to the vendor PKCS#11 shared library on the Enterprise host. |
| `slotId` | One of | The token's `CK_SLOT_ID`. Prefer this — it is stable when other tokens appear. |
| `slotListIndex` | One of | Position in the library's slot list. Convenient for SoftHSM when the slot ID is generated at init. |
| `credentials._type` | Yes | `"Pkcs11PinCredentials"` |
| `credentials.pin` | Yes | Token **user** PIN (not the SO PIN). |
| `providerConfigurationLines` | No | Extra SunPKCS11 config lines for vendor-only requirements. |

**Note:**

Exactly one of `slotId` or `slotListIndex` must be set. Prefer `slotId` for production HSMs.

**Option: SoftHSMv2**

SoftHSMv2 is a software PKCS#11 token. Use it for local development and CI — not as a production root of trust.

**1. Install and create a token**

```bash
# Arch
sudo pacman -S --needed softhsm
# Debian/Ubuntu
sudo apt install softhsm2

export SOFTHSM2_CONF=/opt/waltid/softhsm/softhsm2.conf
mkdir -p /opt/waltid/softhsm/tokens

cat > "$SOFTHSM2_CONF" <<'EOF'
directories.tokendir = /opt/waltid/softhsm/tokens
objectstore.backend = file
log.level = ERROR
slots.removable = false
EOF

softhsm2-util --init-token --free \
  --label enterprise-hsm \
  --so-pin 12345678 \
  --pin 123456
```

**2. Find the slot**

```bash
softhsm2-util --show-slots | grep -m1 '^Slot [0-9]'
# Slot 760482873
```

SoftHSM generates the slot ID when the token is initialized, so the value differs per machine. Note it down, or use
`slotListIndex = 0` when this is the only token in the library.

**3. Add the profile**

```hocon [resource-access.conf]
resourceAccess = {
  softhsm = {
    _type = "Pkcs11Access"
    id = "softhsm"
    libraryPath = "/usr/lib/softhsm/libsofthsm2.so"
    slotId = 760482873
    credentials = {
      _type = "Pkcs11PinCredentials"
      pin = "123456"
    }
  }
}
```

Common library paths:

| Platform | Typical `libraryPath` |
|---|---|
| Arch / some Linux | `/usr/lib/softhsm/libsofthsm2.so` |
| Debian / Ubuntu | `/usr/lib/x86_64-linux-gnu/softhsm/libsofthsm2.so` |

**4. Export `SOFTHSM2_CONF` for the Enterprise process**

The SoftHSM library — not Enterprise — reads this environment variable:

```bash
export SOFTHSM2_CONF=/opt/waltid/softhsm/softhsm2.conf
# systemd: Environment=SOFTHSM2_CONF=/opt/waltid/softhsm/softhsm2.conf
# docker:  -e SOFTHSM2_CONF=... and mount the library + token directory
```

**Option: Thales Luna**

Thales Luna (SafeNet) exposes partitions as PKCS#11 slots. Install the Luna Client on every Enterprise host, register
the client with the HSM, and assign a partition before continuing. See the
[Thales Luna client documentation](https://thalesdocs.com/gphsm/luna/7/docs/network/Content/install/client_install/config-file_summary.htm)
for client install and `Chrystoki.conf` details.

**1. Confirm the library and slot**

On 64-bit Linux the PKCS#11 library is typically:

```text
/usr/safenet/lunaclient/lib/libCryptoki2_64.so
```

Some client installs place it under `/usr/safenet/lunaclient/libs/64/libCryptoki2_64.so`. Use the path that exists on
your hosts.

List slots with LunaCM (`slot list`) or `pkcs11-tool`:

```bash
pkcs11-tool --module /usr/safenet/lunaclient/lib/libCryptoki2_64.so --list-slots
```

Note the partition's `CK_SLOT_ID`. That value is what you put in `slotId`.

**2. Add the profile**

The PIN is the **partition password** (Crypto Officer / Crypto User password for that partition), not an HSM admin PED
key:

```hocon [resource-access.conf]
resourceAccess = {
  luna = {
    _type = "Pkcs11Access"
    id = "luna"
    libraryPath = "/usr/safenet/lunaclient/lib/libCryptoki2_64.so"
    slotId = 0
    credentials = {
      _type = "Pkcs11PinCredentials"
      pin = "partition-password"
    }
  }
}
```

**3. Optional vendor configuration**

If your Luna partition policy requires SunPKCS11 attribute overrides with no portable equivalent, append them with
`providerConfigurationLines`:

```hocon
    providerConfigurationLines = [
      "attributes(*,CKO_PRIVATE_KEY,*) = { CKA_LOCAL = true }"
    ]
```

Each line is appended verbatim to the SunPKCS11 configuration. Newlines inside a line are rejected so a value cannot
inject unrelated directives.

**4. Smoke-test the token (recommended)**

Before relying on a Luna partition in production, run the crypto2 PKCS#11 vendor smoke test against it. It signs with
every algorithm the token advertises:

```bash
WALTID_PKCS11_LIBRARY=/usr/safenet/lunaclient/lib/libCryptoki2_64.so \
WALTID_PKCS11_SLOT_ID=0 \
WALTID_PKCS11_PIN={partitionPassword} \
  ./gradlew :waltid-libraries:crypto:waltid-crypto2-pkcs11:test --tests '*VendorTokenSmokeTest*'
```

---

## Step 3: Create a KMS Bound to the Token

Create a KMS service and point it at the profile with `configRef`. The value must match the profile `id`
(`softhsm` or `luna` in the examples above).

**Option: CURL**

**Endpoint:** `POST /v1/{target}/resource-api/services/create` | [API Reference](https://enterprise.sandbox.walt.id/swagger/index.html#/Service%20%7C%20Resource/post_v1__target__resource_api_services_create)

##### Example Request

```bash
curl -X 'POST' \
  'https://{orgID}.enterprise-sandbox.waltid.dev/v1/{target}/resource-api/services/create' \
  -H 'accept: application/json' \
  -H 'Authorization: Bearer {yourToken}' \
  -H 'Content-Type: application/json' \
  -d '{
  "type": "kms",
  "configRef": "softhsm"
}'
```

**Path Parameters**

- **orgID**: _String_ (required) - Your organization ID. When performing operations within an organization, use the
  organization's Base URL. For example, if your organization is named `test`, your default Base URL will be
  `test.enterprise-sandbox.waltid.dev` when using the sandbox environment.
- **target**: _String_ (required) - The resource identifier indicating the organization + tenant + new KMS service
  (`{organizationID}.{tenantID}.{newKmsServiceID}`). Example: `waltid.tenant1.hsmkms`.

**Header Parameters**

- **Authorization**: _String_ (required) - Bearer token for Enterprise Stack authentication. Format: `Bearer {token}`.

**Body Parameters**

- **type**: _String_ (required) - Service type. Must be `"kms"`.
- **configRef**: _String_ (required) - Profile `id` from `resource-access.conf`. Examples: `"softhsm"`, `"luna"`.

**Response Codes**

- `201` — Service created successfully.
- `401` — Invalid or missing authentication token.

With `configRef` set, later key-generation calls can omit `backend` — the KMS defaults to that profile. You can still
pass `"backend": "softhsm"` (or `"luna"`) explicitly.

---

## Supported Key Algorithms

Subject to what the token actually supports:

- `secp256r1` (P-256)
- `secp384r1` (P-384)
- `secp521r1` (P-521)
- `RSA` (2048-bit)
- `RSA3072` (3072-bit)
- `RSA4096` (4096-bit)

`Ed25519` and `secp256k1` are not supported for PKCS#11 keys. Enterprise probes the token and refuses unsupported types
at generation time with a `400` response.

**Note:**

Keys are **sign / verify only**. RSA encryption and key wrapping are not offered through this integration.

---

## Generate a Key on the Token

**Option: CURL**

**Endpoint:** `POST /v1/{target}/kms-service-api/keys/generate` | [API Reference](https://enterprise.sandbox.walt.id/swagger/index.html#/Service%20%7C%20Key%20Management/post_v1__target__kms_service_api_keys_generate)

##### Example Request

```bash
curl -X 'POST' \
  'https://{orgID}.enterprise-sandbox.waltid.dev/v1/{target}/kms-service-api/keys/generate' \
  -H 'accept: application/json' \
  -H 'Authorization: Bearer {yourToken}' \
  -H 'Content-Type: application/json' \
  -d '{
  "backend": "softhsm",
  "keyType": "secp256r1"
}'
```

For Thales Luna, use `"backend": "luna"` (matching your profile `id`).

**Path Parameters**

- **orgID**: _String_ (required) - Your organization ID. When performing operations within an organization, use the
  organization's Base URL. For example, if your organization is named `test`, your default Base URL will be
  `test.enterprise-sandbox.waltid.dev` when using the sandbox environment.
- **target**: _String_ (required) - The KMS service resource identifier
  (`{organizationID}.{tenantID}.{kmsServiceID}`). Example: `waltid.tenant1.hsmkms`.

**Header Parameters**

- **Authorization**: _String_ (required) - Bearer token for Enterprise Stack authentication. Format: `Bearer {token}`.

**Body Parameters**

- **backend**: _String_ (optional) - PKCS#11 profile `id` from `resource-access.conf`. Omit if the KMS was created with
  `configRef`.
- **keyType**: _String_ (required) - Algorithm to generate. One of the [supported algorithms](#supported-key-algorithms).

---

##### Example Response

The response is a public JWK only — there is no private key material (`d`):

```json
{
  "_id": "waltid.tenant1.hsmkms.019fae71-d211-71eb-9e33-e27ffa96c35a",
  "key": {
    "type": "jwk",
    "jwk": {
      "kty": "EC",
      "crv": "P-256",
      "x": "...",
      "y": "..."
    }
  },
  "metadata": {
    "resource-id": "softhsm"
  }
}
```

**Response Fields**

- **_id**: _String_ - Full key path. Use this as `{target}` for sign, delete, and remove calls.
- **key**: _Object_ - Public key material only (`type: "jwk"`). The private key remains on the token.
- **metadata.resource-id**: _String_ - The `resource-access` profile id used for this key.

**Response Codes**

- `201` — Key created on the token and stored in the KMS.
- `400` — Unsupported key type, or the token refused the request.
- `401` — Invalid or missing authentication token.

🎉 You've generated a signing key that lives on the PKCS#11 token.

---

## Adopt an Existing Key by Alias

In production, HSM keys are often created by whoever administers the token — often under a key ceremony — and Enterprise
is given only an alias. Pass that alias in `config` instead of generating a new key:

**Option: CURL**

**Endpoint:** `POST /v1/{target}/kms-service-api/keys/generate` | [API Reference](https://enterprise.sandbox.walt.id/swagger/index.html#/Service%20%7C%20Key%20Management/post_v1__target__kms_service_api_keys_generate)

##### Example Request

```bash
curl -X 'POST' \
  'https://{orgID}.enterprise-sandbox.waltid.dev/v1/{target}/kms-service-api/keys/generate' \
  -H 'accept: application/json' \
  -H 'Authorization: Bearer {yourToken}' \
  -H 'Content-Type: application/json' \
  -d '{
  "backend": "luna",
  "keyType": "secp256r1",
  "config": {
    "alias": "issuer-signing-key"
  }
}'
```

**Path Parameters**

- **orgID**: _String_ (required) - Your organization ID. When performing operations within an organization, use the
  organization's Base URL.
- **target**: _String_ (required) - The KMS service resource identifier
  (`{organizationID}.{tenantID}.{kmsServiceID}`). Example: `waltid.tenant1.hsmkms`.

**Header Parameters**

- **Authorization**: _String_ (required) - Bearer token for Enterprise Stack authentication. Format: `Bearer {token}`.

**Body Parameters**

- **backend**: _String_ (optional) - PKCS#11 profile `id` from `resource-access.conf`. Omit if the KMS was created with
  `configRef`.
- **keyType**: _String_ (required) - Still required by the API shape; the key's real type is read from the token and
  must be usable for sign / verify.
- **config**: _Object_ (required) - Adoption options.
  - **alias**: _String_ (required) - Alias of an existing private key **with a certificate** on the token.

**Response Codes**

- `201` — Existing token key attached and stored in the KMS.
- `400` — Alias missing on the token, or the key has no certificate.
- `401` — Invalid or missing authentication token.

Nothing is generated. Reading the key from the token proves the alias exists and the configured PIN is correct.

**Info:**

SunPKCS11 addresses keys through a certificate. A key without a certificate on the token is not adoptable. When
Enterprise generates a key itself, it creates a self-signed placeholder certificate for that reason.

---

## Sign With a Token Key

**Option: CURL**

**Endpoint:** `POST /v1/{target}/kms-service-api/keys/sign` | [API Reference](https://enterprise.sandbox.walt.id/swagger/index.html#/Service%20%7C%20Key%20Management/post_v1__target__kms_service_api_keys_sign)

##### Example Request

```bash
curl -X 'POST' \
  'https://{orgID}.enterprise-sandbox.waltid.dev/v1/{target}/kms-service-api/keys/sign' \
  -H 'accept: application/json' \
  -H 'Authorization: Bearer {yourToken}' \
  -H 'Content-Type: application/json' \
  -d '{
  "plaintext": "hello from the HSM"
}'
```

**Path Parameters**

- **orgID**: _String_ (required) - Your organization ID. When performing operations within an organization, use the
  organization's Base URL.
- **target**: _String_ (required) - Full key path returned as `_id` from `keys/generate`. Example:
  `waltid.tenant1.hsmkms.019fae71-d211-71eb-9e33-e27ffa96c35a`.

**Header Parameters**

- **Authorization**: _String_ (required) - Bearer token for Enterprise Stack authentication. Format: `Bearer {token}`.

**Body Parameters**

- **plaintext**: _String_ (required) - The message to sign.

---

##### Example Response

```json
{
  "signature": "MEYCIQ..."
}
```

**Response Fields**

- **signature**: _String_ - Base64-encoded signature produced on the token.

**Response Codes**

- `200` — Signature created successfully.
- `401` — Invalid or missing authentication token.

---

## Delete vs Remove

| Endpoint | Effect on Enterprise record | Effect on token |
|---|---|---|
| `DELETE …/keys/delete` | Removed | Key destroyed on the token |
| `DELETE …/keys/remove` | Removed | Token left untouched |

Use `keys/remove` when you only want to drop the Enterprise reference (for example after migrating the record). Use
`keys/delete` only when the token object itself should be destroyed.

**Error:**

`keys/delete` destroys the private key on the HSM. That is irreversible for keys that cannot be restored from backup.

---

## Operational Notes

- **Multi-replica deployments.** Every replica needs the vendor library at the same `libraryPath` and access to the
  same token. A `resource-access` entry is shared config; what it points at is node-local hardware (or SoftHSM state),
  unlike a cloud KMS endpoint.
- **PIN rotation.** Edit `resource-access.conf` and restart. Key records reference the profile; they do not copy the
  PIN.
- **Wrong PIN detection.** PKCS#11 login state is per process per slot. Once a token is logged in, a later wrong PIN
  may be accepted (`CKR_USER_ALREADY_LOGGED_IN`). A wrong PIN is reliably rejected only on the first login to that
  token within a process.
- **Clean shutdown.** Enterprise logs out of the token when it stops. A hard kill leaves the token logged in until the
  process is gone.

---

## Troubleshooting

| Symptom | Cause |
|---|---|
| Profile missing from `ResourceAccessConfiguration loaded at startup` | `resource-access` not in `enabledFeatures`, or config file not loaded |
| `PKCS11 resource-access reference not found: {id}` | `backend` / `configRef` does not match an `id` in `resource-access.conf` |
| `PKCS11 library does not exist: …` | Wrong `libraryPath`, or the file is not visible to the Enterprise process (common in containers) |
| `Initialization failed` / `CKR_SLOT_ID_INVALID` | Wrong `slotId` / `slotListIndex`. Re-check with `softhsm2-util --show-slots`, LunaCM, or `pkcs11-tool --list-slots` |
| `CKR_TOKEN_NOT_PRESENT` / empty token | Token never initialized, or SoftHSM's `SOFTHSM2_CONF` not set **for the Enterprise process** |
| `CKR_PIN_INCORRECT` | Wrong user PIN in `resource-access.conf` (not the SO PIN) |
| `PKCS11 keys do not support Ed25519` | Expected — use `secp256r1` / `secp384r1` / `secp521r1` or RSA |
| `PKCS11 alias does not exist: {alias}` | Adopting a key that is not on the token, or that has no certificate |
| `PKCS11 token produced an extractable private key` | The token ignored the non-extractability request. Enterprise refuses the key and rolls it back — do not use that token for key generation |

---

## Next Steps

- **Set up a KMS service** — [Setup](https://docs.walt.id/enterprise-stack/services/key-management-service/setup.md).
- **Configure resource-access profiles** — [Resource Access](https://docs.walt.id/enterprise-stack/setup/configurations/config-files/resource-access.md).
- **Use AWS KMS** — [AWS KMS](https://docs.walt.id/enterprise-stack/services/key-management-service/aws-kms.md).
- **Use Azure Key Vault** — [Azure Key Vault](https://docs.walt.id/enterprise-stack/services/key-management-service/azure-key-vault.md).
