HSM Architecture and Principles
A Hardware Security Module is a dedicated piece of hardware, a physical box or a PCIe card, whose sole job is to generate, store, and use cryptographic keys without ever exposing them. The key material lives inside a tamper-resistant boundary. If someone physically breaks into the HSM, the keys are automatically destroyed (this is called zeroization).
The critical property is that keys never leave the HSM in plaintext. You can ask the HSM to encrypt data using a stored key, decrypt data using a stored key, sign a message, verify a signature, but you cannot ask it to give you the key itself. This is fundamentally different from a secrets manager like HashiCorp Vault, where the secrets are stored encrypted but can be retrieved in plaintext.
PKCS#11: The Universal HSM Interface
PKCS#11 (also called Cryptoki) is the standard API for interacting with cryptographic tokens, including HSMs. It's a C API from the 1990s, and it shows, but it's what everything uses.
// pkcs11_example.c - Basic HSM operations via PKCS#11
#include <pkcs11.h>
#include <stdio.h>
#include <string.h>
int main() {
CK_RV rv;
CK_SESSION_HANDLE session;
CK_SLOT_ID slot = 0;
// Initialize the PKCS#11 library
rv = C_Initialize(NULL);
if (rv != CKR_OK) { printf("Init failed: %lx\n", rv); return 1; }
// Open a session
rv = C_OpenSession(slot, CKF_SERIAL_SESSION | CKF_RW_SESSION,
NULL, NULL, &session);
if (rv != CKR_OK) { printf("OpenSession failed: %lx\n", rv); return 1; }
// Login as crypto user
CK_UTF8CHAR pin[] = "crypto-user-pin";
rv = C_Login(session, CKU_USER, pin, sizeof(pin) - 1);
// Generate an AES-256 key inside the HSM
CK_MECHANISM mechanism = { CKM_AES_KEY_GEN, NULL, 0 };
CK_ULONG key_length = 32; // 256 bits
CK_BBOOL true_val = CK_TRUE;
CK_BBOOL false_val = CK_FALSE;
CK_UTF8CHAR label[] = "my-data-encryption-key";
CK_ATTRIBUTE key_template[] = {
{ CKA_TOKEN, &true_val, sizeof(true_val) }, // Persistent
{ CKA_ENCRYPT, &true_val, sizeof(true_val) }, // Can encrypt
{ CKA_DECRYPT, &true_val, sizeof(true_val) }, // Can decrypt
{ CKA_EXTRACTABLE, &false_val, sizeof(false_val) }, // CANNOT export
{ CKA_SENSITIVE, &true_val, sizeof(true_val) }, // Sensitive
{ CKA_LABEL, label, sizeof(label) - 1 },
{ CKA_VALUE_LEN, &key_length, sizeof(key_length) },
};
CK_OBJECT_HANDLE key;
rv = C_GenerateKey(session, &mechanism, key_template,
sizeof(key_template) / sizeof(CK_ATTRIBUTE), &key);
// Encrypt data using the key (key never leaves the HSM)
CK_MECHANISM enc_mechanism = { CKM_AES_GCM, NULL, 0 };
CK_BYTE plaintext[] = "sensitive database password: hunter2";
CK_BYTE ciphertext[256];
CK_ULONG ciphertext_len = sizeof(ciphertext);
// GCM parameters
CK_GCM_PARAMS gcm_params = {
.pIv = (CK_BYTE_PTR)"random-iv-12b",
.ulIvLen = 12,
.ulIvBits = 96,
.pAAD = NULL,
.ulAADLen = 0,
.ulTagBits = 128,
};
enc_mechanism.pParameter = &gcm_params;
enc_mechanism.ulParameterLen = sizeof(gcm_params);
rv = C_EncryptInit(session, &enc_mechanism, key);
rv = C_Encrypt(session, plaintext, sizeof(plaintext) - 1,
ciphertext, &ciphertext_len);
printf("Encrypted %lu bytes\n", ciphertext_len);
// Cleanup
C_Logout(session);
C_CloseSession(session);
C_Finalize(NULL);
return 0;
}
Notice CKA_EXTRACTABLE = false and CKA_SENSITIVE = true. These attributes ensure the key can never be exported from the HSM. The encryption happens inside the HSM, and only the ciphertext comes back. Even a root user on the host machine cannot retrieve the key material.
Cloud KMS: HSMs as a Service
Cloud providers offer KMS services that are backed by HSMs but abstracted behind an API. The three major ones:
AWS KMS
# aws_kms_envelope.py - Envelope encryption with AWS KMS
import boto3
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
kms = boto3.client('kms', region_name='us-east-1')
# The CMK lives in KMS (backed by HSM). You never see the key material.
CMK_KEY_ID = 'arn:aws:kms:us-east-1:123456789:key/mrk-abc123'
def encrypt_data(plaintext: bytes, context: dict) -> dict:
"""
Envelope encryption:
1. KMS generates a data key (inside the HSM)
2. KMS returns plaintext data key + encrypted data key
3. We use plaintext data key to encrypt our data locally
4. We discard plaintext data key
5. We store encrypted data key alongside the ciphertext
"""
# Step 1-2: Generate data key
response = kms.generate_data_key(
KeyId=CMK_KEY_ID,
KeySpec='AES_256',
EncryptionContext=context # Bound to this encryption context
)
plaintext_key = response['Plaintext'] # 32 bytes, use and discard
encrypted_key = response['CiphertextBlob'] # Store this
# Step 3: Encrypt locally with the plaintext data key
nonce = os.urandom(12)
aesgcm = AESGCM(plaintext_key)
ciphertext = aesgcm.encrypt(nonce, plaintext, None)
# Step 4: CRITICAL - zero out the plaintext key
# Python doesn't guarantee this, but we try
plaintext_key = b'\x00' * len(plaintext_key)
del plaintext_key
# Step 5: Return everything needed for decryption
return {
'ciphertext': ciphertext,
'nonce': nonce,
'encrypted_data_key': encrypted_key,
'encryption_context': context,
}
def decrypt_data(envelope: dict) -> bytes:
"""
Reverse the envelope:
1. Send encrypted data key to KMS for decryption
2. KMS decrypts the data key (inside the HSM)
3. Use the plaintext data key to decrypt locally
"""
# Step 1-2: Decrypt the data key
response = kms.decrypt(
CiphertextBlob=envelope['encrypted_data_key'],
EncryptionContext=envelope['encryption_context']
)
plaintext_key = response['Plaintext']
# Step 3: Decrypt locally
aesgcm = AESGCM(plaintext_key)
plaintext = aesgcm.decrypt(
envelope['nonce'],
envelope['ciphertext'],
None
)
# Zero out key
plaintext_key = b'\x00' * len(plaintext_key)
del plaintext_key
return plaintext
# Usage
context = {'service': 'payment-api', 'environment': 'production'}
envelope = encrypt_data(b'database_password=super_secret_123', context)
recovered = decrypt_data(envelope)
The EncryptionContext parameter is often overlooked but it's critical for security. It binds the encrypted data key to a specific context. If someone tries to decrypt the data key with a different context, KMS refuses. This prevents an attacker from taking an encrypted data key from one service and using it in another.
Key Rotation That Doesn't Break Things
# key_rotation.py - Automated key rotation with AWS KMS
import boto3
from datetime import datetime, timedelta
kms = boto3.client('kms')
def setup_automatic_rotation(key_id: str):
"""Enable automatic annual rotation for a CMK."""
kms.enable_key_rotation(KeyId=key_id)
# Verify rotation is enabled
status = kms.get_key_rotation_status(KeyId=key_id)
print(f"Rotation enabled: {status['KeyRotationEnabled']}")
print(f"Next rotation: ~{datetime.now() + timedelta(days=365)}")
def manual_rotation_with_alias(old_key_id: str, alias_name: str):
"""
Manual rotation strategy using aliases.
This is useful when you need to rotate more frequently
than annually, or need to rotate asymmetric keys.
"""
# Create new key
new_key = kms.create_key(
Description=f'Rotated key - {datetime.now().isoformat()}',
KeyUsage='ENCRYPT_DECRYPT',
Origin='AWS_KMS',
Tags=[
{'TagKey': 'RotatedFrom', 'TagValue': old_key_id},
{'TagKey': 'RotationDate', 'TagValue': datetime.now().isoformat()},
]
)
new_key_id = new_key['KeyMetadata']['KeyId']
# Update alias to point to new key
# All new encryptions will use the new key
kms.update_alias(
AliasName=alias_name,
TargetKeyId=new_key_id
)
# OLD KEY REMAINS ACTIVE for decryption
# This is the key insight: you don't re-encrypt existing data
# KMS tracks which key version encrypted each data key
print(f"Alias {alias_name} now points to {new_key_id}")
print(f"Old key {old_key_id} remains active for decryption")
# Schedule old key deletion after grace period (e.g., 90 days)
# Only after you're certain no data encrypted with it remains
# kms.schedule_key_deletion(KeyId=old_key_id, PendingWindowInDays=30)
The Complete Secrets Management Architecture
Here's the architecture I deployed for the client that started this whole journey:
┌─────────────────────────────────────────────────────┐
│ Application Layer │
│ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
│ │ Payment │ │ Auth │ │ Notification │ │
│ │ Service │ │ Service │ │ Service │ │
│ └────┬─────┘ └────┬─────┘ └────────┬─────────┘ │
│ │ │ │ │
│ ┌────▼──────────────▼──────────────────▼────────┐ │
│ │ HashiCorp Vault │ │
│ │ - Dynamic database credentials │ │
│ │ - PKI certificate issuance │ │
│ │ - Transit encryption (delegates to KMS) │ │
│ │ - Lease-based secrets with auto-rotation │ │
│ └────────────────────┬──────────────────────────┘ │
│ │ │
│ ┌────────────────────▼──────────────────────────┐ │
│ │ AWS KMS │ │
│ │ - Vault auto-unseal │ │
│ │ - Envelope encryption for transit engine │ │
│ │ - S3 SSE-KMS for audit logs │ │
│ │ - RDS TDE master key │ │
│ └────────────────────┬──────────────────────────┘ │
│ │ │
│ ┌────────────────────▼──────────────────────────┐ │
│ │ AWS CloudHSM (FIPS 140-2 L3) │ │
│ │ - Custom KMS key store │ │
│ │ - PCI DSS key management │ │
│ │ - Code signing keys │ │
│ └───────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
# vault_config.hcl - Vault configuration with KMS auto-unseal
storage "raft" {
path = "/opt/vault/data"
node_id = "vault-1"
retry_join {
leader_api_addr = "https://vault-2.internal:8200"
}
retry_join {
leader_api_addr = "https://vault-3.internal:8200"
}
}
listener "tcp" {
address = "0.0.0.0:8200"
tls_cert_file = "/opt/vault/tls/vault.crt"
tls_key_file = "/opt/vault/tls/vault.key"
}
# Auto-unseal using AWS KMS
seal "awskms" {
region = "us-east-1"
kms_key_id = "mrk-abc123"
}
# Audit logging to encrypted S3
audit {
type = "file"
path = "file"
options = {
file_path = "/var/log/vault/audit.log"
}
}
telemetry {
prometheus_retention_time = "30s"
disable_hostname = true
}
The three-tier architecture is deliberate:
- Vault handles dynamic secrets (database credentials that rotate every hour), PKI, and application-facing secret delivery.
- KMS handles Vault's auto-unseal (so Vault operators don't need unseal keys) and provides the backing encryption for Vault's transit engine.
- CloudHSM backs the KMS custom key store for PCI-regulated encryption keys. The payment service's card data encryption key chain ultimately roots in hardware.
What I Got Wrong
Two things bit me on this project:
First, I underestimated the cost of CloudHSM. At roughly $1.50/hr per HSM (and you need at least two for HA), that's over $2,000/month just for the HSMs. For most workloads, standard KMS at $1/key/month plus $0.03/10,000 API calls is more than sufficient. CloudHSM is for when you genuinely need FIPS 140-2 Level 3 or single-tenant hardware.
Second, the PKCS#11 tooling ecosystem is painful. The libraries are old, the documentation assumes you're running Solaris, and debugging session errors requires reading hex dumps. If you can stay in the cloud KMS API layer, do it. Drop to PKCS#11 only when you must.
The .env file is gone. The database passwords rotate hourly. The audit trail shows exactly who accessed what key, when. And the next security review? Passed without findings. That felt good.