Skip to content

05 Stored Credentials Lld

Stored Credentials & API Layer — Low Level Design

Section titled “Stored Credentials & API Layer — Low Level Design”

This document covers three patterns that apply to different layers of the stored credentials system: Proxy controls and defers access to encrypted payment profiles, Singleton ensures the HSM client is a single shared instance, and Decorator adds cross-cutting concerns (logging, rate limiting, encryption) to the payment API without modifying its core logic.


Design the stored credentials and API layer for a payment gateway. The system must:

  • Store encrypted payment profiles (customer cards) and control who can decrypt them — display operations should never trigger HSM decryption
  • Ensure the HSM client is a single shared instance across the JVM — the HSM has limited connections and expensive initialization
  • Add cross-cutting concerns (logging, rate limiting, idempotency, MLE decryption) to the payment API in a composable, independently-testable way

All three design challenges below are driven by PCI DSS Level 1 compliance requirements — the regulatory framework that mandates HSM use, encrypted storage, access control, memory zeroing, and audit logging for any entity that stores cardholder data at scale. Without PCI DSS, these constraints would read as arbitrary. With it, they are non-negotiable.

The key design challenges:

  1. Minimal decryption surface: decrypting a stored PAN triggers an HSM operation and exposes card data in memory. The system must decrypt only when actually needed (charging), never for display operations.
  2. HSM connection scarcity: HSM devices support ~10-100 concurrent sessions. Multiple instances each with their own connection pools would exhaust this limit.
  3. Cross-cutting concern composition: the payment API needs logging, rate limiting, idempotency, and MLE decryption — all independently configurable per endpoint, none mixed into the core processing logic.

Q: What operations does the stored credentials system support? When is decryption actually needed?

A: Four operations: getDisplayInfo() (last four digits for UI — never needs decryption), getPaymentMethod() (full view including encrypted data — decrypted only if caller has PAYMENT_METHOD_READ permission), charge() (decrypts PAN, submits to processor, zeros memory), updatePaymentMethod() (re-encrypts with new PAN). Decryption happens only in charge() — all other operations work with masked or encrypted data.

Q: How many getDisplayInfo() calls happen per second vs charge() calls?

A: Display calls are ~10× more frequent than charge calls (every page load that shows saved cards triggers display). Display must be <5ms (Redis/DB cache, no HSM). Charge can take up to 200ms including HSM decryption and processor call. The Proxy’s optimization of bypassing HSM for display is a significant throughput win.

Q: What must NEVER happen with stored card data?

A: (1) The decrypted PAN must never be written to a log file, database field (other than encrypted_pan), or HTTP response. (2) The decrypted PAN must be zeroed from memory immediately after use (Arrays.fill(plainPan, (byte) 0)). (3) Every decryption event must be audit logged — who, which profile, when. PCI DSS requires this.

Q: Can new decorators be added to the API pipeline without changing existing code?

A: Yes — the Decorator pattern explicitly supports this. A new FraudPreScreenDecorator can be inserted between IdempotencyDecorator and MleDecryptionDecorator in HandlerFactory with zero changes to existing decorators. Order matters and is documented in HandlerFactory.

Q: Is the HSM client thread-safe? Can two threads call decrypt() simultaneously?

A: Yes — the HsmConnectionPool manages concurrent sessions. Each decrypt() call borrows a session, uses it, and returns it. Sessions are not shared between threads. Bill Pugh Singleton initialization is thread-safe via JVM class-loading.

Q: What happens if the HSM is unavailable during a charge?

A: The charge() call in RealPaymentMethodService catches HsmUnavailableException and returns a ChargeResult.failed("HSM_UNAVAILABLE"). The transaction remains in PENDING state and can be retried. The Proxy propagates this failure to the caller — no silent data corruption.

Q: PCI compliance audit: show all decryption events for payment profile pm_xyz in the last 30 days.

A: The Proxy’s AuditLogger writes every PAYMENT_METHOD_ACCESSED and PAYMENT_METHOD_CHARGE_INITIATED event to audit_log with: entity_id=pm_xyz, actor=service-name, timestamp, operation. Queryable: SELECT * FROM audit_log WHERE entity_id='pm_xyz' AND event_type LIKE 'PAYMENT_METHOD_%' AND created_at > NOW() - INTERVAL 30 DAYS.

Q: Where are encryption keys stored? What happens if the HSM loses its keys?

A: Master keys are stored in the HSM hardware only — never in software. HSM clusters in two datacenters with key synchronization. DEKs (Data Encryption Keys) are stored in the DB as wrapped blobs (encrypted by a wrapping key in the HSM). Key recovery requires M-of-N operator key shares.

Q: What PCI DSS level applies, and which specific requirements directly shape this design?

A: PCI DSS Level 1 (>6M transactions/year). Four requirements directly shape the stored credentials design: (1) Req 3.3 — PAN must be encrypted at rest using strong cryptography (AES-256 with HSM-managed keys satisfies this). (2) Req 3.2 — CVV must never be stored. The data model has no CVV field; it is used transiently during authorization and never persisted. (3) Req 7 — restrict access to cardholder data by business need. The Proxy pattern enforces this: the reporting service never gets a decrypted PAN — it uses getDisplayInfo() which the proxy serves without calling the HSM. (4) Req 10.3 — log all access to cardholder data with timestamps, user identity, and outcome. The AuditLogger in the proxy satisfies this for every decryption event.

Q: How often are Data Encryption Keys (DEKs) rotated? What does rotation require operationally?

A: DEKs are rotated annually per PCI DSS guidance (Req 3.6.4). Rotation process: (1) Generate new DEK inside the HSM. (2) Background job reads each payment_method record in batches of 1,000/minute. (3) For each: unwrap old DEK via HSM, decrypt encrypted_pan, re-encrypt with new DEK, update encrypted_pan and key_handle in DB atomically. (4) Once all records migrated, retire the old DEK. The key_handle column is essential — it tells the decryption service which key version encrypted each record, allowing old and new DEKs to coexist during the migration window. Total rotation for 10M stored cards at 1,000/minute takes ~7 days.

Q: Should new payment methods be stored as encrypted PANs or provisioned as Visa/Mastercard network tokens?

A: Network tokens are preferred for all recurring billing use cases. A Visa Token Service (VTS) or Mastercard MDES token: (a) is automatically updated when the customer’s card is reissued — no subscription failure; (b) carries a per-transaction cryptogram (TAVV) that issuers trust more highly → 2–5% approval rate lift; (c) reduces PCI scope — the network token is not the PAN, so even if the payment_methods table is exfiltrated, the tokens are less dangerous than raw PANs. Encrypted PAN storage remains as a fallback for card types not yet supported by token services (some regional banks, commercial cards). The payment_methods table has both encrypted_pan and network_token_reference columns — whichever is non-null is used for charging.

Q: When a customer’s card expires and is replaced with a new card number, how do stored credentials stay current?

A: Two mechanisms: (1) Network tokens (preferred) — VTS/MDES automatically updates the token-to-PAN mapping when the issuer reissues the card. The stored credential (network token reference) continues working without any action. (2) Account Updater service (fallback for encrypted PAN storage) — ANET/Visa runs a batch service: weekly, submit truncated card numbers (BIN + last 4) for all stored profiles to the card network. Network returns: new PAN (card reissued), new expiry (extension), or closed account. For updated cards: decrypt old PAN, re-encrypt new PAN via HSM, update record. Account Updater costs ~$0.02 per checked card but prevents involuntary subscription churn from card reissuances.

Q: In a multi-merchant gateway, how is stored credential data isolated between merchants?

A: Isolation operates at three levels: (1) Data: every customer and payment_method record has merchant_id as a foreign key and NOT NULL constraint. Every query is scoped by merchant_id. A DB-level row-security policy (PostgreSQL RLS) can enforce this at the storage engine level — no application-layer bug can leak cross-merchant data. (2) Access control: AccessControlService.checkPermission() validates that the requesting service’s merchant context matches the payment_method.merchant_id. (3) Key isolation (optional for highest-security merchants): a dedicated DEK per merchant rather than a shared DEK. This way, even a DEK compromise only exposes one merchant’s cards, not all merchants’. Typically only implemented for enterprise merchants with contractual isolation requirements.


Section 1: Proxy Pattern — Encrypted Payment Profile

Section titled “Section 1: Proxy Pattern — Encrypted Payment Profile”

The problem: without a proxy, every call to getPaymentMethod() loads the full encrypted blob and decrypts it — even when the caller only needs the card’s last-four digits for display. This unnecessarily exercises the HSM and risks exposing decrypted PAN data.

plantuml

PaymentMethodView.java
public class PaymentMethodView {
private final String pmId;
private final String cardType;
private final String lastFour;
private final int expiryMonth;
private final int expiryYear;
private final DecryptedPan decryptedPan; // null until decrypted
public boolean isPanDecrypted() { return decryptedPan != null; }
// constructor + getters
}
CardDisplayInfo.java
public class CardDisplayInfo {
private final String maskedNumber; // e.g., "**** **** **** 4242"
private final String cardType;
private final String expiryDisplay; // e.g., "12/27"
// constructor + getters
}
PaymentMethodServiceProxy.java
public class PaymentMethodServiceProxy implements PaymentMethodService {
private final RealPaymentMethodService realService;
private final AccessControlService accessControl;
private final AuditLogger auditLogger;
public PaymentMethodServiceProxy(RealPaymentMethodService realService,
AccessControlService accessControl,
AuditLogger auditLogger) {
this.realService = realService;
this.accessControl = accessControl;
this.auditLogger = auditLogger;
}
@Override
public PaymentMethodView getPaymentMethod(String pmId) {
accessControl.checkPermission("PAYMENT_METHOD_READ", pmId);
auditLogger.log("PAYMENT_METHOD_ACCESSED", pmId, getCurrentServiceIdentity());
return realService.getPaymentMethod(pmId);
}
@Override
public ChargeResult charge(String pmId, BigDecimal amount) {
accessControl.checkPermission("PAYMENT_METHOD_CHARGE", pmId);
auditLogger.log("PAYMENT_METHOD_CHARGE_INITIATED", pmId, getCurrentServiceIdentity(),
Map.of("amount", amount.toString()));
ChargeResult result = realService.charge(pmId, amount);
auditLogger.log("PAYMENT_METHOD_CHARGE_COMPLETED", pmId, getCurrentServiceIdentity(),
Map.of("success", result.isSuccess(), "authCode", result.getAuthCode()));
return result;
}
@Override
public CardDisplayInfo getDisplayInfo(String pmId) {
// PROXY HANDLES DIRECTLY — no HSM call needed for display
// Load only the masked fields from DB (last_four, card_type, expiry)
StoredProfile profile = profileRepository.loadDisplayOnly(pmId);
return new CardDisplayInfo(
"**** **** **** " + profile.getLastFour(),
profile.getCardType(),
profile.getExpiryMonth() + "/" + profile.getExpiryYear()
);
// Note: realService.getPaymentMethod() is NOT called here
}
private String getCurrentServiceIdentity() {
return SecurityContext.getCurrentServicePrincipal().getName();
}
}
  • Separation of concerns: security checks and audit logging in the proxy — RealPaymentMethodService stays clean
  • Performance: getDisplayInfo() never calls the HSM — saves ~5ms latency for every card display
  • Audit trail: every decryption event logged with caller identity — required for PCI DSS compliance
  • Extra layer: proxy adds one indirection; bugs in proxy (wrong permission check, missing audit) are security-critical
  • Test complexity: must test both proxy behavior and real service behavior independently
AlternativeWhy it fails for encrypted payment profiles
Put access control in RealPaymentMethodServiceService is now responsible for both business logic AND security — violates SRP. Security code is harder to audit when mixed with decryption logic.
AOP / @PreAuthorize annotationsWorks for simple role checks but cannot implement the “display never decrypts” optimization. AOP intercepts at the method level — can’t change return value behavior based on caller intent.
Manually add permission checks at each call siteEvery caller must remember to check. A new call site that forgets = a security hole.
ProxySecurity and audit in one class. getDisplayInfo() short-circuits — proxy handles it directly without calling the real service. All HSM-touching operations audited centrally. Real service stays clean.

Section 2: Singleton Pattern — HSM Client

Section titled “Section 2: Singleton Pattern — HSM Client”

d2

Why Singleton for HSM:

  • HSM devices have a limited number of concurrent sessions (typically 10–100)
  • Each session initialization requires a PIN entry or key ceremony — expensive operation
  • Connection pooling must be centralized — multiple instances would each create their own pools
  • Thread-safe: HSM operations are atomic — concurrent threads can share the single instance safely

Use Bill Pugh Singleton (best practice for lazy initialization without synchronized overhead):

HsmClient.java
public class HsmClient {
private final HsmConnectionPool connectionPool;
private final HsmAuthConfig authConfig;
private HsmClient() {
// Private constructor — expensive initialization happens here
this.authConfig = HsmAuthConfig.fromEnvironment();
this.connectionPool = HsmConnectionPool.builder()
.host(authConfig.getHost())
.port(authConfig.getPort())
.partitionLabel(authConfig.getPartitionLabel())
.pinProvider(authConfig.getPinProvider())
.maxConnections(authConfig.getMaxConnections())
.build();
connectionPool.initialize();
validateConnection();
}
// Bill Pugh Singleton — lazy initialization, thread-safe without synchronization
private static class HsmClientHolder {
private static final HsmClient INSTANCE = new HsmClient();
}
public static HsmClient getInstance() {
return HsmClientHolder.INSTANCE;
}
public byte[] decrypt(byte[] encryptedData, String keyHandle) {
HsmSession session = connectionPool.borrowSession();
try {
return session.decrypt(encryptedData, keyHandle);
} finally {
connectionPool.returnSession(session);
}
}
public byte[] encrypt(byte[] plaintext, String keyHandle) {
HsmSession session = connectionPool.borrowSession();
try {
return session.encrypt(plaintext, keyHandle);
} finally {
connectionPool.returnSession(session);
}
}
private void validateConnection() {
HsmSession testSession = connectionPool.borrowSession();
try {
testSession.performSelfTest(); // HSM push test
} finally {
connectionPool.returnSession(testSession);
}
}
}

Usage across multiple services in the same JVM:

DecryptionService.java
4 collapsed lines
public class DecryptionService {
private final HsmClient hsmClient = HsmClient.getInstance(); // same instance everywhere
public PaymentMethod decryptStoredCard(String pmId, byte[] encryptedPan) {
String keyHandle = keyRegistry.getKeyHandle(pmId);
byte[] plainPan = hsmClient.decrypt(encryptedPan, keyHandle);
// plainPan exists in memory only for this method's duration
PaymentMethod method = buildPaymentMethod(pmId, plainPan);
Arrays.fill(plainPan, (byte) 0); // zero out in memory immediately after use
return method;
}
}
  • Resource efficiency: one connection pool shared across all services — no over-provisioning HSM connections
  • Thread-safe by design: Bill Pugh pattern uses JVM class-loading guarantees — no explicit synchronization needed
  • Centralized monitoring: one instance → one health check, one reconnection logic, one metrics endpoint
  • Testing difficulty: HsmClient.getInstance() is hard to mock. Use dependency injection: accept HsmClient as a constructor parameter in services that need it, and inject a test double.
  • JVM restart required to reconfigure: HSM host/port/credentials read at initialization time — changing them requires restart

Why Singleton Pattern and Not Alternatives

Section titled “Why Singleton Pattern and Not Alternatives”
AlternativeWhy it fails for HSM client
New HsmClient instance per serviceEach instance creates its own connection pool. 5 services = 5 pools = potentially 250 HSM connections (far exceeding the HSM’s limit of 10-100).
Spring @Bean (prototype scope)A new bean per injection point — same problem as above. Must use @Bean with singleton scope (which is Spring’s default).
Static methods on HsmClientStatic methods are untestable (can’t mock). Session management becomes global mutable state.
SingletonOne connection pool shared across all services in the JVM. Bill Pugh gives lazy init without synchronization overhead. Services accept HsmClient via constructor for testability.

Section 3: Decorator Pattern — Payment API Request Pipeline

Section titled “Section 3: Decorator Pattern — Payment API Request Pipeline”

Without Decorator, the PaymentApiHandler would contain:

  • Logging code
  • Rate limit checking code
  • MLE decryption code
  • Idempotency key checking code
  • Core transaction logic

That’s 5 concerns in one class — violates Single Responsibility Principle and makes it hard to test each concern in isolation.

plantuml

PaymentRequestHandler.java
public interface PaymentRequestHandler {
PaymentResponse handle(PaymentRequest request);
}
PaymentRequestDecorator.java
public abstract class PaymentRequestDecorator implements PaymentRequestHandler {
protected final PaymentRequestHandler wrappedHandler;
public PaymentRequestDecorator(PaymentRequestHandler wrappedHandler) {
this.wrappedHandler = wrappedHandler;
}
}
LoggingDecorator.java
4 collapsed lines
public class LoggingDecorator extends PaymentRequestDecorator {
private final StructuredLogger logger;
public LoggingDecorator(PaymentRequestHandler wrapped, StructuredLogger logger) {
super(wrapped);
this.logger = logger;
}
@Override
public PaymentResponse handle(PaymentRequest request) {
long startMs = System.currentTimeMillis();
logger.info("payment_request_received", Map.of(
"merchantId", request.getMerchantId(),
"requestId", request.getRequestId(),
"type", request.getType()
// NOTE: never log card data, CVV, transaction key
));
PaymentResponse response = wrappedHandler.handle(request);
logger.info("payment_request_completed", Map.of(
"requestId", request.getRequestId(),
"success", response.isSuccess(),
"latencyMs", System.currentTimeMillis() - startMs
));
return response;
}
}
RateLimitDecorator.java
public class RateLimitDecorator extends PaymentRequestDecorator {
private final RateLimiterService rateLimiter;
@Override
public PaymentResponse handle(PaymentRequest request) {
RateLimitResult limit = rateLimiter.checkLimit(request.getMerchantId());
if (limit.isExceeded()) {
return PaymentResponse.rateLimitExceeded(
limit.getRetryAfterSeconds(),
"Merchant " + request.getMerchantId() + " rate limit exceeded"
);
}
return wrappedHandler.handle(request);
}
}
IdempotencyDecorator.java
public class IdempotencyDecorator extends PaymentRequestDecorator {
private final IdempotencyStore idempotencyStore;
@Override
public PaymentResponse handle(PaymentRequest request) {
String key = request.getIdempotencyKey();
if (key != null) {
Optional<PaymentResponse> cached = idempotencyStore.get(request.getMerchantId(), key);
if (cached.isPresent()) {
return cached.get(); // return cached response — no duplicate processing
}
}
PaymentResponse response = wrappedHandler.handle(request);
if (key != null) {
idempotencyStore.store(request.getMerchantId(), key, response, Duration.ofHours(24));
}
return response;
}
}
HandlerFactory.java
public class HandlerFactory {
public static PaymentRequestHandler buildChain(PaymentGatewayFacade facade,
StructuredLogger logger,
RateLimiterService rateLimiter,
IdempotencyStore idempotency,
MleDecryptionService mle) {
// Build from inside out — last decorator is the outermost (first to execute)
PaymentRequestHandler core = new CorePaymentHandler(facade);
PaymentRequestHandler withMle = new MleDecryptionDecorator(core, mle);
PaymentRequestHandler withIdem = new IdempotencyDecorator(withMle, idempotency);
PaymentRequestHandler withRate = new RateLimitDecorator(withIdem, rateLimiter);
PaymentRequestHandler withLog = new LoggingDecorator(withRate, logger);
return withLog;
// Execution order: LoggingDecorator → RateLimitDecorator → IdempotencyDecorator → MleDecryptionDecorator → CorePaymentHandler
}
}
  • Single responsibility: each decorator handles exactly one concern — easy to test in isolation
  • Composable: webhook endpoints can skip MleDecryptionDecorator; internal service calls can skip RateLimitDecorator
  • Open for extension: add FraudPreScreenDecorator without touching existing decorators
  • Debugging complexity: a failure deep in the chain shows a stack trace through all decorators — can be confusing
  • Order matters: logging before rate limiting means you log even rate-limited requests; decide consciously

Why Decorator Pattern and Not Alternatives

Section titled “Why Decorator Pattern and Not Alternatives”
AlternativeWhy it fails for API request pipeline
All concerns in one PaymentApiHandler class5 concerns × complex logic = 500-line class. Cannot test logging without also running rate limiting. Adding a new concern requires modifying the existing handler.
Filter chain (Servlet filters)Works for HTTP-level concerns (authentication, logging) but not for application-level concerns (idempotency, MLE decryption) that need access to parsed domain objects.
AOP (@Around annotations)Powerful but implicit — the execution order of multiple aspects is not obvious from reading the code. Decorator makes the chain explicit in HandlerFactory.
DecoratorEach concern is a self-contained class with one responsibility. HandlerFactory makes the chain order explicit and readable. Webhook endpoints skip MleDecryptionDecorator by building a different chain. Each decorator is unit-testable with a mock wrappedHandler.

← Payment Gateway LLD