Production-Grade Spring Boot API Encryption: RSA + AES-GCM Protocol & Implementation
This article details a production-ready end-to-end encryption protocol for Spring Boot APIs using RSA-OAEP for key wrapping and AES-GCM for payload encryption, covering protocol specification, replay protection with Redis, key rotation strategies, filter-based decryption, testing failure paths, observability, and deployment validation.
Determine If This Protocol Solves Your Problem
HTTPS is the default answer. It protects confidentiality, integrity, and server identity between client and TLS termination point. If the requirement is only "don't get sniffed", fix TLS configuration, certificates, and gateway instead of inventing another crypto layer.
Application-layer message encryption has clear value when TLS terminates at the gateway but the gateway must not read fields like ID numbers, bank card numbers, or transaction instructions. The client hands ciphertext to the gateway; only authorized business services can decrypt.
Client -- HTTPS + ciphertext --> WAF / Gateway -- mTLS + ciphertext --> Payment Service -- plaintext --> business code
does not hold decryption private key holds decryption capabilityThis protocol does not replace authentication, authorization, business idempotency, risk control, storage encryption, or audit. A compromised client can still send valid encrypted requests. For service-to-service communication, prefer mTLS / Service Mesh; for static sensitive data, design envelope encryption and KMS separately. Do not use this for file uploads: large files should use pre-signed object storage URLs; if client-side encryption is required, design a separate streaming protocol.
The boundary adopted here is: request decrypts in Payment Service; Gateway only does routing, authentication, rate limiting, and replay pre-filtering without reading plaintext . If the gateway decrypts, it becomes a high-value plaintext node and must have tightened permissions, audit, and internal links.
Security Goals and Invariants
The protocol must solve four things: confidentiality, tamper resistance, short-term replay protection, and key rotatability. Other problems are explicitly delegated to other mechanisms.
The following invariants must be jointly protected by code and runtime configuration:
Under the same AES key, GCM IV must never repeat; this protocol generates a fresh 32-byte AES key and 12-byte random IV per request.
Any metadata participating in routing or interpreting ciphertext must enter AAD, and the AAD byte sequence must be cross-language unique.
Plaintext is handed to the Controller only when the time window is valid, keyId is usable, nonce atomic acquisition succeeds, and GCM authentication passes.
Every instance reads keys from the same logical KeySet; local cache cannot be the source of truth.
Encryption failures must not leak BadPaddingException, key state, or plaintext to client, logs, or metric labels.
Crypto Protocol v3: Define Bytes Before Writing SDK
Don't just write "RSA + AES". Cross-language interoperability usually fails on OAEP's MGF1 digest, Base64 variants, GCM tag concatenation, or AAD string concatenation. Below is the complete, testable protocol definition.
Algorithms and Encoding
Fixed values:
Key Wrapping: RSAES-OAEP, digest SHA-256, MGF1 digest SHA-256, empty label
Data Encryption: AES-256-GCM, no padding
AES Key: per-request random 32 bytes
IV: per-request random 12 bytes, transmitted with ciphertext
GCM Tag: 16 bytes, using library output ciphertext||tag Random Source: OS CSPRNG / Java SecureRandom Text Encoding: UTF-8
Binary-to-Text: RFC 4648 base64url, without padding
Time: Unix epoch seconds, decimal integer
Nonce: random 16 bytes, base64url encoded
RSA only wraps the 32-byte AES key; never encrypt the whole JSON. RSA has input length limits and is unsuitable for business payloads. AES-GCM already authenticates both ciphertext and AAD; do not stack custom combos like "CBC + MD5".
Request Envelope
Protected JSON interfaces use application/vnd.acme.crypto+json;v=3; protocol layer rejects multipart/form-data. All fields are mandatory; unknown fields should be rejected to prevent silent downgrade.
{
"version": "3",
"keyId": "pay-api-2026-09",
"requestId": "01K4XQ5FNYBPQK34DWF4E7MJ86",
"timestamp": 1788940800,
"nonce": "gHaw98ZXix8W7QIfqI0mXg",
"encryptedKey": "base64url(RSA-OAEP(aesKey))",
"iv": "base64url(12 random bytes)",
"ciphertext": "base64url(aesGcmCiphertextAndTag)"
} requestIdis a log/trace correlation ID, not a nonce, not a business idempotency key. Payment, account opening, and other side-effect interfaces must still require an independent Idempotency-Key, protected by a unique constraint in the business table.
Precise AAD Definition
The following ambiguous practices are prohibited: version + "|" + keyId + "|" + path Once a field allows a delimiter, or different implementations handle URI encoding differently, the verified bytes are no longer the same. The protocol encodes each UTF-8 field as 4-byte unsigned big-endian length || bytes, concatenated in field order; timestamp uses 8-byte signed big-endian. method is uppercase HTTP method, path is the raw request path (no query string, must be forwarded unchanged by gateway).
AAD = lp(version) || lp(keyId) || lp(requestId) || int64(timestamp)
|| lp(nonce) || lp(method) || lp(path)This means an attacker moving ciphertext from POST /api/payments to POST /api/refunds, or replacing keyId, timestamp, will cause GCM tag verification to fail. Server must still do format, version, time window, and path policy checks before decryption; AAD authentication is not input validation.
Request Processing Order: Replay Protection Facts and Recovery
Allowing 300 seconds clock skew is a suggested value ; determine based on client clock sync capability and max network latency. Nonce TTL must cover the entire acceptance window plus a small safety margin; here 600 seconds is used.
1. Limit body size, parse and strictly validate Envelope format, version, content type, and path policy
2. Verify timestamp in [now - 300s, now + 300s]; reject if expired
3. Read decryptable private key by keyId; reject unknown or retired key
4. Atomically consume (clientId, keyId, nonce) via Redis SET key value NX EX 600
5. RSA-OAEP unwrap AES key, rebuild AAD per spec, AES-GCM decrypt
6. Only after tag verification succeeds, hand plaintext JSON to Spring MVCStep 4 is the concurrency protection point: two pods receiving the same request, only one successfully consumes the nonce, the other gets CRYPTO_REPLAY. Redis is the shared store for the fact that nonce is consumed; local memory cache only works for single-instance deployment and loses state on restart, cannot replace Redis.
The cost is an attacker who obtains a nonce can consume it with malformed ciphertext, causing the legitimate request to be rejected; this is acceptable fail-closed behavior. Using authenticated principal as Redis key prefix, rate limiting at gateway, and monitoring nonce conflict rate can control this attack surface. Do not delete nonce on GCM failure , otherwise attacker can infinitely retry tampered versions of the same request.
When Redis is briefly unavailable, high-risk interfaces like payment must reject rather than degrade to "no replay protection"; return a unified retryable error for on-call handling. Low-risk read interfaces may choose fail-open only if explicitly approved in risk review, not as an implicit fallback in code.
Spring Boot Minimal Implementation Package
The code below uses Java 21 and Spring Boot 3 ( jakarta.servlet). It is not a complete starter, but key invariants, inputs, errors, and concurrency boundaries are in the code, not hidden in doSomething().
Protocol Model, AAD, and Algorithm Implementation
package com.acme.crypto;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.OAEPParameterSpec;
import javax.crypto.spec.PSource;
import java.io.ByteArrayOutputStream;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.PrivateKey;
import java.security.SecureRandom;
import java.security.spec.MGF1ParameterSpec;
import java.time.Instant;
import java.util.Base64;
public record CryptoEnvelope(
String version, String keyId, String requestId, long timestamp,
String nonce, String encryptedKey, String iv, String ciphertext) {}
final class ProtocolV3 {
static final String VERSION = "3";
static final int AES_KEY_BYTES = 32;
static final int IV_BYTES = 12;
static final int TAG_BITS = 128;
static final Base64.Decoder B64 = Base64.getUrlDecoder();
static byte[] aad(CryptoEnvelope e, String method, String path) {
var out = new ByteArrayOutputStream();
writeLp(out, e.version());
writeLp(out, e.keyId());
writeLp(out, e.requestId());
out.writeBytes(ByteBuffer.allocate(Long.BYTES).putLong(e.timestamp()).array());
writeLp(out, e.nonce());
writeLp(out, method.toUpperCase(java.util.Locale.ROOT));
writeLp(out, path);
return out.toByteArray();
}
private static void writeLp(ByteArrayOutputStream out, String value) {
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
out.writeBytes(ByteBuffer.allocate(Integer.BYTES).putInt(bytes.length).array());
out.writeBytes(bytes);
}
static byte[] decode(String value, int expectedLength, String field) {
try {
if (value.contains("=") || !value.matches("[A-Za-z0-9_-]+")) throw invalid(field);
byte[] bytes = B64.decode(value);
if (expectedLength >= 0 && bytes.length != expectedLength) throw invalid(field);
return bytes;
} catch (IllegalArgumentException ex) {
throw invalid(field);
}
}
static CryptoException invalid(String field) {
return new CryptoException(CryptoError.INVALID_ENVELOPE, "invalid " + field);
}
}
final class HybridDecryptor {
private static final SecureRandom RANDOM = new SecureRandom();
private static final OAEPParameterSpec OAEP_SHA256 = new OAEPParameterSpec(
"SHA-256", "MGF1", MGF1ParameterSpec.SHA256, PSource.PSpecified.DEFAULT);
byte[] decrypt(CryptoEnvelope envelope, PrivateKey privateKey, String method, String path) {
try {
byte[] encryptedKey = ProtocolV3.decode(envelope.encryptedKey(), -1, "encryptedKey");
Cipher rsa = Cipher.getInstance("RSA/ECB/OAEPPadding");
rsa.init(Cipher.DECRYPT_MODE, privateKey, OAEP_SHA256, RANDOM);
byte[] keyBytes = rsa.doFinal(encryptedKey);
if (keyBytes.length != ProtocolV3.AES_KEY_BYTES) {
throw new CryptoException(CryptoError.INVALID_CIPHERTEXT, "wrong AES key length");
}
Cipher aes = Cipher.getInstance("AES/GCM/NoPadding");
aes.init(Cipher.DECRYPT_MODE,
new javax.crypto.spec.SecretKeySpec(keyBytes, "AES"),
new GCMParameterSpec(ProtocolV3.TAG_BITS,
ProtocolV3.decode(envelope.iv(), ProtocolV3.IV_BYTES, "iv")));
aes.updateAAD(ProtocolV3.aad(envelope, method, path));
return aes.doFinal(ProtocolV3.decode(envelope.ciphertext(), -1, "ciphertext"));
} catch (CryptoException ex) {
throw ex;
} catch (GeneralSecurityException ex) {
// External response must not distinguish RSA failure, tag failure, or provider details.
throw new CryptoException(CryptoError.INVALID_CIPHERTEXT, "cipher authentication failed", ex);
}
}
}
enum CryptoError { INVALID_ENVELOPE, INVALID_TIMESTAMP, UNKNOWN_KEY, REPLAY,
INVALID_CIPHERTEXT, BODY_TOO_LARGE, REPLAY_STORE_UNAVAILABLE }
final class CryptoException extends RuntimeException {
final CryptoError error;
CryptoException(CryptoError error, String message) { super(message); this.error = error; }
CryptoException(CryptoError error, String message, Throwable cause) { super(message, cause); this.error = error; }
}JCE Cipher is not thread-safe, so the example creates a new Cipher per operation but reuses the thread-safe PrivateKey object. Do not build a Cipher object pool until real workload proves it's a bottleneck. Private keys can be parsed and cached per keyId, but cache invalidation, refresh, and permission checks must be handled by KeyProvider.
Key State and Shared Nonce Fact
KeyProvidercannot just return a "current private key". It must return a private key with state ACTIVE or DECRYPT_ONLY for the request's keyId, and reject RETIRED / REVOKED. KMS, HSM, or Secret Manager is the source of truth; multi-pod local caches only reduce read count.
public interface KeyProvider {
java.security.PrivateKey privateKeyForDecrypt(String keyId);
}
public interface ReplayProtector {
/** Success means this request is the first to consume the nonce; false means already consumed by any pod. */
boolean consume(String clientId, String keyId, String nonce, java.time.Duration ttl);
}
@Component
final class RedisReplayProtector implements ReplayProtector {
private final org.springframework.data.redis.core.StringRedisTemplate redis;
RedisReplayProtector(org.springframework.data.redis.core.StringRedisTemplate redis) { this.redis = redis; }
public boolean consume(String clientId, String keyId, String nonce, java.time.Duration ttl) {
String key = "crypto:nonce:" + clientId + ':' + keyId + ':' + nonce;
try {
return Boolean.TRUE.equals(redis.opsForValue().setIfAbsent(key, "1", ttl));
} catch (org.springframework.data.redis.RedisConnectionFailureException e) {
throw new CryptoException(CryptoError.REPLAY_STORE_UNAVAILABLE, "replay store unavailable", e);
}
}
} clientIdcomes from the already authenticated principal or mTLS identity, not from the envelope. For legacy protocols where authentication depends on request body, refactor the authentication boundary first; do not trust unauthenticated clientId for decryption.
Filter: Controller Still Receives Original DTO
The filter should run before authentication/authorization filters that depend on the body; if identity is only in the Authorization header, authentication can run first. Order is not a fixed number but a dependency relationship: "who needs plaintext runs first". Health checks, metrics endpoints, and explicitly public endpoints must be excluded; high-security APIs use default-include, explicit-exclude strategy.
@Component
@Order(org.springframework.core.Ordered.HIGHEST_PRECEDENCE + 20)
final class CryptoRequestFilter extends org.springframework.web.filter.OncePerRequestFilter {
private static final java.time.Duration SKEW = java.time.Duration.ofSeconds(300);
private static final java.time.Duration NONCE_TTL = java.time.Duration.ofSeconds(600);
private final com.fasterxml.jackson.databind.ObjectMapper json;
private final KeyProvider keys;
private final ReplayProtector replay;
private final HybridDecryptor decryptor = new HybridDecryptor();
CryptoRequestFilter(com.fasterxml.jackson.databind.ObjectMapper json, KeyProvider keys, ReplayProtector replay) {
this.json = json; this.keys = keys; this.replay = replay;
}
@Override
protected boolean shouldNotFilter(jakarta.servlet.http.HttpServletRequest request) {
String p = request.getRequestURI();
return p.startsWith("/actuator/") || p.equals("/health") || !p.startsWith("/api/");
}
@Override
protected void doFilterInternal(jakarta.servlet.http.HttpServletRequest request,
jakarta.servlet.http.HttpServletResponse response,
jakarta.servlet.FilterChain chain)
throws java.io.IOException, jakarta.servlet.ServletException {
try {
if (!"application/vnd.acme.crypto+json;v=3".equals(request.getContentType())) {
throw new CryptoException(CryptoError.INVALID_ENVELOPE, "unexpected content type");
}
CryptoEnvelope envelope = json.readValue(readAtMost(request, 1_048_576), CryptoEnvelope.class);
validateEnvelope(envelope);
String clientId = authenticatedClientId(request); // from trusted auth layer, missing = reject
if (!replay.consume(clientId, envelope.keyId(), envelope.nonce(), NONCE_TTL)) {
throw new CryptoException(CryptoError.REPLAY, "nonce already used");
}
byte[] plaintext = decryptor.decrypt(envelope, keys.privateKeyForDecrypt(envelope.keyId()),
request.getMethod(), request.getRequestURI());
chain.doFilter(new PlainBodyRequest(request, plaintext), response);
} catch (CryptoException e) {
writePublicError(response, e);
} catch (com.fasterxml.jackson.core.JacksonException e) {
writePublicError(response, new CryptoException(CryptoError.INVALID_ENVELOPE, "malformed JSON", e));
}
}
private static void validateEnvelope(CryptoEnvelope e) {
if (e == null || !ProtocolV3.VERSION.equals(e.version()) || e.keyId() == null || e.requestId() == null
|| e.nonce() == null || e.encryptedKey() == null || e.iv() == null || e.ciphertext() == null) {
throw ProtocolV3.invalid("required field");
}
ProtocolV3.decode(e.nonce(), 16, "nonce");
if (Math.abs(java.time.Instant.now().getEpochSecond() - e.timestamp()) > SKEW.toSeconds()) {
throw new CryptoException(CryptoError.INVALID_TIMESTAMP, "timestamp outside allowed window");
}
}
private static byte[] readAtMost(jakarta.servlet.http.HttpServletRequest request, int max) throws java.io.IOException {
if (request.getContentLengthLong() > max) throw new CryptoException(CryptoError.BODY_TOO_LARGE, "too large");
try (var in = request.getInputStream(); var out = new java.io.ByteArrayOutputStream()) {
byte[] buffer = new byte[8192];
for (int n; (n = in.read(buffer)) != -1;) {
if (out.size() + n > max) throw new CryptoException(CryptoError.BODY_TOO_LARGE, "too large");
out.write(buffer, 0, n);
}
return out.toByteArray();
}
}
private static String authenticatedClientId(jakarta.servlet.http.HttpServletRequest request) {
Object value = request.getAttribute("authenticatedClientId");
if (!(value instanceof String id) || id.isBlank()) throw new CryptoException(CryptoError.INVALID_ENVELOPE, "unauthenticated client");
return id;
}
private static void writePublicError(jakarta.servlet.http.HttpServletResponse response, CryptoException error) throws java.io.IOException {
response.setStatus(error.error == CryptoError.REPLAY ? 409 : 400);
response.setContentType("application/json");
response.getWriter().write("{\"code\":\"CRYPTO_INVALID_REQUEST\",\"message\":\"Invalid encrypted request\"}");
}
} PlainBodyRequestmust truly replace getInputStream() and getReader(); merely caching the original stream without returning decrypted bytes would let MVC read ciphertext again. Implementation:
final class PlainBodyRequest extends jakarta.servlet.http.HttpServletRequestWrapper {
private final byte[] body;
PlainBodyRequest(jakarta.servlet.http.HttpServletRequest request, byte[] body) {
super(request);
this.body = body.clone();
}
@Override
public jakarta.servlet.ServletInputStream getInputStream() {
java.io.ByteArrayInputStream input = new java.io.ByteArrayInputStream(body);
return new jakarta.servlet.ServletInputStream() {
@Override public int read() { return input.read(); }
@Override public int read(byte[] b, int off, int len) { return input.read(b, off, len); }
@Override public boolean isFinished() { return input.available() == 0; }
@Override public boolean isReady() { return true; }
@Override public void setReadListener(jakarta.servlet.ReadListener listener) {
throw new UnsupportedOperationException("async IO is not supported by this wrapper");
}
};
}
@Override
public java.io.BufferedReader getReader() {
return new java.io.BufferedReader(
new java.io.InputStreamReader(getInputStream(), java.nio.charset.StandardCharsets.UTF_8));
}
@Override public int getContentLength() { return body.length; }
@Override public long getContentLengthLong() { return body.length; }
@Override public String getContentType() { return "application/json"; }
}This is a blocking Servlet chain; if the application uses async ReadListener or WebFlux, do not copy directly; implement the same protocol invariants in their respective buffering and backpressure models. To encrypt responses, use a response wrapper capturing limited-size plaintext, clear original Content-Length, then write encrypted envelope; do not wrap streaming responses.
The example uses exact Content-Type match as protocol requirement. In production, if parameters must be accepted, use MediaType.parseMediaType then compare type/subtype and v parameter; do not use contains("application/json") which would silently let non-protocol JSON into the decryption path.
Response Direction: Don't Assume Client Private Key Exists
Response encryption reuses the model "server generates AES key, AES-GCM encrypts response, wraps key with client public key via OAEP", which only holds when the client has a protectable private key. Examples: service-to-service, device hardware Keystore with registered device public key. Browser JavaScript and ordinary app-bundled "private keys" are not reliable secrets; most app responses should continue relying on HTTPS.
If response encryption is enabled, AAD must include response direction, request's requestId, HTTP status, method, path, and client keyId to prevent swapping a success response onto another request. Response Content-Length must be recalculated for the new envelope; whether 4xx/5xx are encrypted must be part of the protocol, not a mix of plaintext and ciphertext errors.
Configuration, Key Rotation, and Multi-Replica
The configuration below is a behavioral contract, not a place to put private keys in YAML. Private keys are supplied to KeyProvider by KMS / HSM / Secret Manager; in Kubernetes, External Secrets can deliver short-lived credentials or references, but application images, Git, and ordinary logs must never store private keys.
crypto:
enabled: true
protocol-version: "3"
request:
max-envelope-bytes: 1048576 # example: only allow 1 MiB encrypted JSON
timestamp-skew-seconds: 300
replay:
ttl-seconds: 600
fail-mode: CLOSED # reject protected write interfaces when Redis unavailable
paths:
include: ["/api/**"]
exclude: ["/actuator/**", "/health"]
keys:
refresh-seconds: 60In Kubernetes, pods should call Secret Manager / KMS via workload identity, or have External Secrets sync controlled references ; the deployment snippet only shows injecting a reference, not writing private keys into the repo. Deployment validation must confirm ServiceAccount can only read its own namespace's secrets, secrets are not printed to CI logs, and during rolling updates every replica can read the same KeySet.
apiVersion: apps/v1
kind: Deployment
metadata:
name: payment-api
spec:
template:
spec:
serviceAccountName: payment-api-key-reader
containers:
- name: app
env:
- name: CRYPTO_KEYSET_REFERENCE
value: "sm://prod/payment-api/keyset" # reference, not private key valueEach pod must observe a consistent KeySet from the same Key Registry. Parsed PrivateKey objects may be cached, but cache expiry or push invalidation must trigger refresh; during rotation, Pod A must not only know the new key while Pod B only knows the old key.
DRAFT
-> Static validation: keyId uniqueness, legal state transitions, KMS permissions
-> SDK interop vectors: Java / Go / iOS fixed test vectors all pass
-> Canary: new key ACTIVE, old key DECRYPT_ONLY
-> Observe: unknown_key, old key usage, GCM failure rate, p99
-> Full rollout: client public key caches updated
-> RETIRED: old key usage drops to zero and exceeds max request lifetime, then revokeOld key retention must cover at least: max timestamp acceptance window, client public key cache TTL, canary duration, and longest retry chain. Do not arbitrarily write "retain 24 hours". Rollback restores Last Known Good public key publishing state , keeping both new and old private keys decryptable until in-flight requests naturally expire; never delete private keys immediately for rollback.
Suspected private key compromise is not a normal rotation: immediately mark the key REVOKED, block new requests for that key, publish new public key, investigate usage records. This will fail clients still caching the old public key; security takes precedence over smooth transition, so prepare client refresh and user notification strategies in advance.
Replay Protection ≠ Business Idempotency
Nonce only prevents the same encrypted request from being replayed within the valid window. After a network timeout, the client may generate a new nonce and new AES key to retry; the server cannot infer from this whether the previous payment was persisted. Therefore, side-effect interfaces still need a stable Idempotency-Key, storing the first execution result with a unique constraint on (client_id, idempotency_key).
create table idempotency_record (
client_id varchar(64) not null,
idempotency_key varchar(128) not null,
request_hash char(64) not null,
status varchar(16) not null,
response_body jsonb,
created_at timestamptz not null,
primary key (client_id, idempotency_key)
);Who writes: the service handling the business command, writing in the same DB transaction as the business state change. Who reads: the retry path. On conflict: first transaction's unique constraint wins; second request reads existing record, if request_hash differs return conflict, if same and completed return first result. External payment call timeout should transition to UNKNOWN state, driven by saved PSP request ID query or webhook to recover; never switch channels and resend directly.
Must-Test Failure Paths
Tests should not only assert "can decrypt". The table below covers each protocol protection point; item 7 verifies shared facts under multi-replica concurrency.
Test cases:
AAD Tampering : Same ciphertext with modified path, method, or keyId → GCM verification fails, no Controller or DB write.
Ciphertext Tampering : Flip any bit in ciphertext/tag → INVALID_CIPHERTEXT, nonce retained until TTL expiry.
Timestamp Expired : Timestamp outside window → Redis not written, unified invalid request returned.
Duplicate Ciphertext : Same envelope submitted twice serially → First may enter business; second returns 409, no decryption.
Semantic Retry with New Nonce : Same Idempotency-Key, different envelope → Both nonces consumable; DB unique constraint returns first business result.
Key Rotation : Old decrypt-only, new active → Both decryptable; retired key rejected.
Dual Pod Concurrency : Two threads call Redis with same nonce → Only one SET NX EX succeeds, other gets REPLAY.
Redis Failure : Replay store connection fails → Write interfaces fail-closed, no business write, metric increments.
Use Testcontainers with real Redis for items 7 and 8; pure mocks cannot prove SET NX EX atomicity and TTL behavior. Cross-language SDKs maintain versioned fixed vectors: given private key, public key, AES key, IV, timestamp, and AAD, every byte of the expected envelope must match; random values use fixed test values only in tests, never in production code.
Performance and Capacity: Validate with Workload, Not Fixed Millisecond Promises
RSA private key operations, JDK provider, pod CPU limits, request size, TLS, JSON serialization, and GC all affect latency. Any "RSA decrypt fixed 5 ms" conclusion does not apply to your environment. First, load test three groups with real key sizes, typical and max body, concurrency levels: TLS only, TLS + envelope, TLS + envelope + Redis.
Record RPS, CPU throttling, heap, GC, Redis p99, RSA/AES latency, end-to-end p95/p99, and error rates; set launch thresholds, e.g., "encrypted path p99 must not exceed TLS-only baseline by a reviewed increment", not copying numbers from this article. If RSA becomes a bottleneck, first confirm whether requests mistakenly perform multiple RSA ops or per-field RSA, then consider more efficient key agreement; do not introduce Cipher object pools without load testing.
Observability and Incident Response
Logs only record correlation and classification: requestId, trusted clientId, keyId, protocol version, body length, result, internal error category, and latency. Strictly forbidden: logging AES key, private key, full ciphertext, plaintext body, Authorization, Cookie, bank card, or ID fields. Metric labels must not use high-cardinality fields like requestId or nonce.
Recommended minimum metrics:
crypto_requests_total{result,version}
crypto_failures_total{category}
crypto_replay_rejected_total
crypto_key_requests_total{key_state}
crypto_decrypt_seconds (histogram)
crypto_replay_store_failures_totalObservation signals and on-call actions:
UNKNOWN_KEY rising → Client public key cache or rotation publish inconsistency → Compare key registry, SDK key cache, canary scope; roll back to Last Known Good if needed.
GCM/RSA auth failures rising → SDK/AAD version mismatch, tampering, or wrong key → Bucket by version/keyId, compare against fixed interop vectors; do not log plaintext.
Replay rejections rising → Client retry bug, duplicate concurrency, or attack → Check clientId and rate limit logs; do not clear nonces.
Replay store unavailable → Redis failure → Keep rejecting protected write interfaces, verify metric recovery after Redis fix.
p99 and CPU rise together → RSA or container throttling → Check CPU limit, actual request size, RSA invocation count; scale or optimize per load test conclusions.
Launch Validation: Verify Pre-Launch, During Canary, and Rollback
Pre-launch : Fixed test vectors covering Unicode, empty JSON, max body, bad base64, bad GCM tag, mutated method/path, expired timestamp, duplicate nonce, unknown key, old key decryption, and response length; cross-language SDKs must be bidirectional interoperable. Also verify /actuator/health bypasses filter, multipart is not read into memory, error responses don't leak exception details.
During canary : First deploy server side reading new public key and supporting decrypt-only, then route a deterministic clientId bucket of clients to new key. Shadow compute only parses metadata and records "which key would be chosen", must not decrypt and emit a second payment or other remote side effects. Observe no-candidate/unknown key, error rates, body size distribution, Redis p99, and crypto p99; expand only if clean.
Rollback : Stop publishing new public key to new clients, restore Last Known Good public key; server retains decrypt capability for both new and old private keys until in-flight requests expire. Rollback is not deleting the new key. If it's a leak event, execute the emergency revocation process above, not a normal rollback.
Common but Dangerous Simplifications
RSA encrypting entire JSON : Length-limited and expensive; only encrypt AES key.
AES-CBC + MD5/SHA : Delegating authentication design to error-prone custom protocol; use AEAD (AES-GCM).
Fixed IV : Reusing IV under same key breaks GCM security; use unique random 12-byte IV per request.
Allow after decryption succeeds : Ciphertext can be fully replayed; time window and nonce atomic consumption are both mandatory.
Using requestId as nonce : Trace ID generation, exposure, and lifecycle are not equivalent to cryptographic randomness.
Hand-written decryption in every Controller : Protocol upgrades become repo-wide copy-paste; centralize in filter / starter and SDK.
Private keys in Git, images, or application.yml : Every read surface expands leak radius; obtain from KMS / Secret Manager with permissions.
Treating "internal network" as security boundary : SSRF, lateral movement, and misconfigurations still happen; use mTLS and identity authorization for service-to-service.
Conclusion
The hard part of production-grade API encryption is not Cipher.doFinal(), but explicitly defining who can see plaintext and ensuring protocol, state, concurrency, and operations all obey that decision: HTTPS protects the link, RSA-OAEP delivers a one-time AES key, AES-GCM with canonical AAD protects the message, Redis records the nonce-consumed fact, business unique constraints handle semantic retries, KMS manages key lifecycle, and metrics plus drills prove it behaves as expected under failure.
If you cannot provide a threat model, protocol byte definition, KeySet source of truth, Redis unavailability behavior, and business UNKNOWN recovery, you should not yet call the solution "production-grade".
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Cloud Architecture
Focuses on cloud‑native and distributed architecture engineering, sharing practical solutions and lessons learned. Covers microservice governance, Kubernetes, observability, and stability engineering to help your systems run stable, fast, and cost‑effectively.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
