Keys, Signing, JWKS & TLS
These concepts are the foundation of modern secure communications. Understanding them deeply separates engineers who "just use HTTPS" from engineers who can design secure systems.
Public Key vs Private Key β The Core Idea
Asymmetric cryptography uses a mathematically linked key pair:
π Asymmetric Cryptography: Public & Private Keys
π Public Key (Shared Freely)
Distributed to anyone who wants to verify your messages or encrypt data for you.
π Private Key (Kept Secret)
Must remain safely locked in a secure key store (Vault/HSM). Never transmitted over networks.
π‘ Core Rule: Data encrypted with the Public Key can only be decrypted with the Private Key. Conversely, signatures generated with the Private Key can be verified by anyone who holds the corresponding Public Key.
Analogy
Think of a padlock and key:
- Public key = the open padlock you hand out to everyone
- Private key = the only key that opens the padlock
Anyone can lock (encrypt) a message using your padlock. Only you can unlock (decrypt) it.
For signing, invert the analogy: you seal a document with your private seal (private key), and anyone with a copy of your seal-stamp (public key) can verify the seal is genuine.
Digital Signing β How It Works Step by Step
Signing proves who created a message and that it hasn't been tampered with.
βοΈ Digital Signing vs. Verification Step-by-Step
Signing Process: Instead of encrypting the entire payload directly (which is slow for large datasets), the application computes a fast 32-byte SHA-256 hash. The hash is then encrypted using the sender's Private Key to produce the Signature, which is attached alongside the payload.
Why Sign the Hash, Not the Payload?
- Performance β RSA/ECDSA is slow on large data. Hash is always 32 bytes regardless of payload size.
- Security property β SHA-256 is a one-way function. You can't reconstruct the payload from the hash.
Java Implementation
// βββ Signing βββββββββββββββββββββββββββββββββββββββββββββββββββ
public byte[] signPayload(byte[] payload, PrivateKey privateKey) throws Exception {
Signature signer = Signature.getInstance("SHA256withRSA"); // or SHA256withECDSA
signer.initSign(privateKey);
signer.update(payload);
return signer.sign(); // Returns the signature bytes
}
// βββ Verification βββββββββββββββββββββββββββββββββββββββββββββββ
public boolean verifySignature(byte[] payload, byte[] signature, PublicKey publicKey)
throws Exception {
Signature verifier = Signature.getInstance("SHA256withRSA");
verifier.initVerify(publicKey);
verifier.update(payload);
return verifier.verify(signature); // true = valid signature
}
// βββ Key generation (run once, store private key in Vault) ββββββ
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
kpg.initialize(4096, new SecureRandom());
KeyPair keyPair = kpg.generateKeyPair();
// Export for storage
String privateKeyPem = "-----BEGIN PRIVATE KEY-----\n"
+ Base64.getMimeEncoder().encodeToString(keyPair.getPrivate().getEncoded())
+ "\n-----END PRIVATE KEY-----";
String publicKeyPem = "-----BEGIN PUBLIC KEY-----\n"
+ Base64.getMimeEncoder().encodeToString(keyPair.getPublic().getEncoded())
+ "\n-----END PUBLIC KEY-----";
A JWT is a signed payload. The signature is computed over Base64Url(header) + "." + Base64Url(payload).
βοΈ JWT Signature Construction
π‘ End-to-End JWT Signing: By concatenating the header and payload and signing the resulting string, the authentication server guarantees that if any claim inside the token payload (e.g. user ID or roles) is modified by the client, the signature check will fail locally on the API server.
// βββ Issuing a signed JWT (Auth Server) βββββββββββββββββββββββββ
@Service
public class JwtIssuerService {
private final RSAPrivateKey privateKey;
public String issueToken(String userId, List<String> roles) {
return JWT.create()
.withIssuer("https://auth.example.com")
.withSubject(userId)
.withAudience("https://api.example.com")
.withIssuedAt(new Date())
.withExpiresAt(Date.from(Instant.now().plus(15, MINUTES)))
.withJWTId(UUID.randomUUID().toString()) // jti β for revocation
.withClaim("roles", roles)
.withKeyId("key-2024-01") // kid β tells verifier which key to use
.sign(Algorithm.RSA256(null, privateKey));
}
}
// βββ Verifying a JWT (Resource Server / Spring Boot) ββββββββββββ
// Spring's JwtDecoder handles this automatically via JWKS
@Bean
public JwtDecoder jwtDecoder() {
return JwtDecoders.fromIssuerLocation("https://auth.example.com");
// Internally: fetches https://auth.example.com/.well-known/openid-configuration
// β fetches jwks_uri β caches public keys β verifies signature on each request
}
JWKS β JSON Web Key Sets
JWKS is the standard way for an authorization server to publish its public keys so resource servers can verify JWTs without sharing a secret.
The Problem JWKS Solves
WITHOUT JWKS:
Auth Server shares RSA private key secret β resource servers verify JWTs
Problem: Any service with the secret can FORGE JWTs!
WITH JWKS (RS256):
Auth Server keeps private key SECRET
Auth Server publishes PUBLIC keys at: /.well-known/jwks.json
Resource servers download public keys, verify signatures
Result: Anyone can verify, but ONLY auth server can sign
JWKS Endpoint Format
// GET https://auth.example.com/.well-known/jwks.json
{
"keys": [
{
"kty": "RSA", // Key type
"use": "sig", // Usage: sig (signing) or enc (encryption)
"alg": "RS256", // Algorithm
"kid": "key-2024-01", // Key ID β matches JWT header "kid"
"n": "sIwr7...", // RSA modulus (Base64Url)
"e": "AQAB" // RSA exponent (Base64Url)
},
{
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"kid": "key-2024-02", // New key (rotation in progress)
"n": "tLxa9...",
"e": "AQAB"
}
]
}
How JWT Verification Works with JWKS
1. Resource server receives JWT with header: { "alg": "RS256", "kid": "key-2024-01" }
2. Resource server fetches JWKS (cached, refreshed periodically):
GET https://auth.example.com/.well-known/jwks.json
3. Find key where jwks.kid == jwt.header.kid β "key-2024-01"
4. Reconstruct RSA PublicKey from (n, e) values
5. Verify JWT signature using the public key β β
or β
Implementing a JWKS Endpoint in Spring Boot (Auth Server)
@RestController
public class JwksController {
private final KeyStore keyStore;
@GetMapping("/.well-known/jwks.json")
public Map<String, Object> jwks() {
List<Map<String, Object>> keys = new ArrayList<>();
// Publish all ACTIVE public keys (include both old and new during rotation)
for (KeyPair keyPair : keyStore.getActiveKeyPairs()) {
RSAPublicKey rsaPublic = (RSAPublicKey) keyPair.getPublic();
keys.add(Map.of(
"kty", "RSA",
"use", "sig",
"alg", "RS256",
"kid", keyStore.getKeyId(keyPair),
"n", Base64.getUrlEncoder().withoutPadding()
.encodeToString(rsaPublic.getModulus().toByteArray()),
"e", Base64.getUrlEncoder().withoutPadding()
.encodeToString(rsaPublic.getPublicExponent().toByteArray())
));
}
return Map.of("keys", keys);
}
}
Key Rotation with JWKS β Zero Downtime Strategy
π Zero-Downtime Key Rotation Strategy (JWKS)
JWKS Public Key Set
Active Signing Key (Auth Server)
Before Rotation
Only one key exists in the JWKS configuration. Tokens are issued and verified using key-2024-01.
@Service
public class KeyRotationService {
@Scheduled(cron = "0 0 2 1 * ?") // 1st of month at 2 AM
public void rotateSigningKey() {
KeyPair newKeyPair = generateRsaKeyPair(4096);
String newKeyId = "key-" + YearMonth.now().toString();
// 1. Add to JWKS (resource servers will pick it up on next cache refresh)
keyStore.addKey(newKeyId, newKeyPair);
// 2. Wait for JWKS cache TTL to expire (e.g., 5 minutes)
// In practice: use a feature flag or delayed switch
// 3. Switch new token signing to new key
keyStore.setActiveSigningKey(newKeyId);
// 4. Schedule removal of old key after max token TTL
scheduler.schedule(() -> keyStore.removeKey(previousKeyId),
Duration.ofMinutes(15 + 5)); // access token TTL + buffer
}
}
Spring Boot β Configure Resource Server with JWKS
# application.yml
spring:
security:
oauth2:
resourceserver:
jwt:
# Spring fetches + caches JWKS automatically
jwk-set-uri: https://auth.example.com/.well-known/jwks.json
# OR use issuer-uri and Spring finds JWKS via OpenID discovery
issuer-uri: https://auth.example.com
@Bean
public JwtDecoder jwtDecoder(@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") String jwksUri) {
NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(jwksUri)
.cache(Duration.ofMinutes(5)) // Cache JWKS for 5 minutes
.build();
// Add custom validators
OAuth2TokenValidator<Jwt> validators = new DelegatingOAuth2TokenValidator<>(
new JwtTimestampValidator(Duration.ofSeconds(30)), // 30s clock skew tolerance
new JwtIssuerValidator("https://auth.example.com"),
new JwtAudienceValidator("https://api.example.com")
);
decoder.setJwtValidator(validators);
return decoder;
}
JWS and JWE β JSON Web Signatures & Encryption
When working with JSON-formatted tokens (specifically JSON Web Tokens / JWTs), raw encryption or signing operations are structured into formalized envelopes standardized by the JOSE (JSON Object Signing and Encryption) Working Group. These standards ensure interoperability across different systems and programming languages.
- JWS (JSON Web Signature - RFC 7515): Focuses on Authenticity and Integrity.
- JWE (JSON Web Encryption - RFC 7516): Focuses on Confidentiality.
1. JSON Web Signature (JWS)
A JWS protects the payload against tampering and guarantees its source. The data inside a standard JWS is base64url-encoded but readable (not encrypted). Anyone who intercepts the token can read the claims.
Structure of JWS (3 Parts)β
βοΈ Interactive JWS (JSON Web Signature) Anatomy
JWS Part 1: Header
The JWS Header contains metadata describing the signature type. "alg" defines the signature algorithm (RS256 - RSA Signature with SHA-256). "typ" defines the token type, and "kid" is the key identifier helping the recipient locate the correct public verification key.
Parsed / Value
{
"alg": "RS256",
"typ": "JWT",
"kid": "key-2024-01"
}π‘ Hover over the three JWS segments (Header, Payload, Signature) to decode them.
- Header: Specifies the metadata, including the signing algorithm (e.g.,
HS256for HMAC,RS256for RSA signatures,ES256for ECDSA). - Payload: The actual JSON claim set (e.g., user profile, roles, scopes).
- Signature: Generated by hash-signing the header and payload together with the secret or private key.
Nimbus JOSE Implementation: RSA-256 JWS Signing & Verificationβ
import com.nimbusds.jose.*;
import com.nimbusds.jose.crypto.*;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
public class JwsService {
// 1. Sign a payload using a Private RSA Key
public String signPayload(String payloadContent, RSAPrivateKey privateKey) throws Exception {
JWSSigner signer = new RSASSASigner(privateKey);
JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.RS256)
.type(JOSEObjectType.JWT)
.build();
JWSObject jwsObject = new JWSObject(header, new Payload(payloadContent));
jwsObject.sign(signer);
return jwsObject.serialize(); // Output: Header.Payload.Signature
}
// 2. Verify and extract payload using a Public RSA Key
public String verifyAndExtract(String serializedJws, RSAPublicKey publicKey) throws Exception {
JWSObject jwsObject = JWSObject.parse(serializedJws);
JWSVerifier verifier = new RSASSAVerifier(publicKey);
if (!jwsObject.verify(verifier)) {
throw new JWSVerificationException("JWS signature verification failed! Data tampered.");
}
return jwsObject.getPayload().toString();
}
}
2. JSON Web Encryption (JWE)
A JWE encrypts the payload, ensuring that its contents are completely opaque to intermediate routers, clients, or interceptors. Only the holder of the corresponding decryption key can read it.
Structure of JWE (5 Parts)β
π¦ Interactive JWE (JSON Web Encryption) Anatomy
JWE Part 1: Header
The JWE Protected Header defines the algorithms used. "alg" is the asymmetric key wrap algorithm (RSA-OAEP-256) used to encrypt the content encryption key (CEK). "enc" is the symmetric encryption method (AES-256-GCM) used to encrypt the payload.
Parsed / Value
{
"alg": "RSA-OAEP-256",
"enc": "A256GCM",
"kid": "key-2024-01"
}π‘ Hover over each of the 5 segments of the JWE string to decode them.
- Protected Header: Envelope parameters, defining the key management algorithm (
alg) and symmetric content encryption algorithm (enc). - Encrypted Key: The symmetric Content Encryption Key (CEK), encrypted using the recipient's public key (RSA-OAEP).
- Initialization Vector (IV): A random value required by the symmetric cipher (e.g., 96-bit for AES-GCM) to ensure uniqueness.
- Ciphertext: The actual encrypted payload.
- Authentication Tag: The integrity verification check tag generated by the AEAD cipher (AES-GCM) to prevent tampering.
Nimbus JOSE Implementation: RSA-OAEP + AES-GCM JWE Encryption & Decryptionβ
import com.nimbusds.jose.*;
import com.nimbusds.jose.crypto.*;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
public class JweService {
// 1. Encrypt payload using recipient's Public RSA Key
public String encryptPayload(String payloadContent, RSAPublicKey publicKey) throws Exception {
// alg: RSA-OAEP-256 for key wrapping; enc: AES-256-GCM for content encryption
JWEHeader header = new JWEHeader.Builder(JWEAlgorithm.RSA_OAEP_256, EncryptionMethod.A256GCM)
.contentType("JWT") // Indicator if nesting a JWS inside
.build();
JWEObject jweObject = new JWEObject(header, new Payload(payloadContent));
jweObject.encrypt(new RSAEncrypter(publicKey));
return jweObject.serialize(); // Output: Header.EncryptedKey.IV.Ciphertext.AuthTag
}
// 2. Decrypt payload using recipient's Private RSA Key
public String decryptPayload(String serializedJwe, RSAPrivateKey privateKey) throws Exception {
JWEObject jweObject = JWEObject.parse(serializedJwe);
jweObject.decrypt(new RSADecrypter(privateKey));
return jweObject.getPayload().toString();
}
}
JWS vs. JWE Comparison
| Dimension | JWS (JSON Web Signature) | JWE (JSON Web Encryption) |
|---|---|---|
| Security Property | Integrity and Authenticity (Non-repudiation). | Confidentiality and Integrity. |
| Visibility | Payload is base64-encoded, completely readable by anyone. | Payload is encrypted, completely opaque. |
| Number of Parts | 3 parts (Header.Payload.Signature). | 5 parts (Header.Key.IV.Ciphertext.Tag). |
| Key Use | Sender signs with Private Key (or shared secret); Recipient verifies with Public Key (or shared secret). | Sender encrypts with Recipient's Public Key; Recipient decrypts with Private Key. |
| Use Cases | Identity assertion, authorization tokens (standard JWTs), session exchange. | PII protection, passing API access keys in front-channel, sensitive payloads. |
Production Best Practice: Nested JWT (Sign-then-Encrypt)
The Problem with pure JWE: Anyone can encrypt a payload with your public key and send you a valid JWE. While a JWE ensures the data wasn't modified in transit, it does not prove who generated the encrypted payload.
The Solution: Use a Nested JWT.
- The sender first signs the payload to create a JWS.
- The sender then encrypts the signed JWS inside a JWE container using the recipient's public key.
- The recipient decrypts the outer JWE envelope first, then validates the inner JWS signature. This guarantees both Confidentiality and Authenticity.
// Conceptual flow using Nimbus
String claims = "{\"sub\":\"user123\",\"role\":\"admin\"}";
// Step 1: Sign (JWS)
String jwsToken = jwsService.signPayload(claims, senderPrivateKey);
// Step 2: Encrypt the JWS token itself (JWE)
String nestedJwt = jweService.encryptPayload(jwsToken, recipientPublicKey);
Message-Level Encryption (MLE)
TLS protects the transport layer β but what happens when TLS is terminated at a proxy, load balancer, or API gateway? The payload travels in plaintext within your internal network.
π TLS Termination vs. Message-Level Encryption (MLE)
β οΈ Standard TLS Termination: The load balancer or API Gateway terminates the HTTPS connection. From the gateway to internal servers (across switches, databases, and log forwarders), the payload is sent in plaintext. If the gateway logs requests or if a database leaks, sensitive user data is exposed.
When to Use MLE
- Payment APIs β card data encrypted with bank's public key, decrypted only in HSM
- Healthcare β PHI encrypted end-to-end, only the treating application can decrypt
- Open Banking β regulatory requirement in some jurisdictions (e.g., RBI in India)
- Highly regulated data β must ensure not even internal proxies can see the data
- Non-repudiation β client signs the payload, proving they sent it
MLE Request Pattern (Client Encrypts with Server's Public Key)
π¦ Message-Level Encryption (MLE) Packaging
1. Generate Session Key (CEK)
Client generates a single-use random symmetric AES-256 key (Content Encryption Key β CEK).
Cryptographic Output
CEK = AES_Key_Gen()
JWE (JSON Web Encryption) β The Standard for MLE
JWE Compact Serialization:
BASE64URL(header) . BASE64URL(encrypted_cek) . BASE64URL(iv) . BASE64URL(ciphertext) . BASE64URL(auth_tag)
Header example:
{
"alg": "RSA-OAEP-256", // Algorithm for encrypting the CEK
"enc": "A256GCM", // Algorithm for encrypting the payload
"kid": "key-2024-01" // Which server public key to use
}
// βββ Client Side: Encrypt request payload βββββββββββββββββββββββ
@Service
public class MleClientService {
private final RSAPublicKey serverPublicKey; // fetched from JWKS
public String encryptPayload(String jsonPayload) throws Exception {
// 1. Generate fresh CEK
KeyGenerator keyGen = KeyGenerator.getInstance("AES");
keyGen.init(256, new SecureRandom());
SecretKey cek = keyGen.generateKey();
// 2. Encrypt CEK with server's RSA public key (RSA-OAEP)
Cipher rsaCipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding");
rsaCipher.init(Cipher.ENCRYPT_MODE, serverPublicKey);
byte[] encryptedCek = rsaCipher.doFinal(cek.getEncoded());
// 3. Encrypt payload with CEK (AES-256-GCM)
byte[] iv = new byte[12];
new SecureRandom().nextBytes(iv);
Cipher aesCipher = Cipher.getInstance("AES/GCM/NoPadding");
aesCipher.init(Cipher.ENCRYPT_MODE, cek, new GCMParameterSpec(128, iv));
byte[] ciphertext = aesCipher.doFinal(jsonPayload.getBytes(StandardCharsets.UTF_8));
// 4. Build JWE-like structure
return buildJwe(encryptedCek, iv, ciphertext);
}
}
// βββ Server Side: Decrypt request payload βββββββββββββββββββββββ
@Service
public class MleServerService {
private final RSAPrivateKey serverPrivateKey; // stored in Vault / HSM
public String decryptPayload(String jweToken) throws Exception {
JweParts parts = parseJwe(jweToken);
// 1. Decrypt CEK with server's private key
Cipher rsaCipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding");
rsaCipher.init(Cipher.DECRYPT_MODE, serverPrivateKey);
byte[] cek = rsaCipher.doFinal(parts.encryptedCek());
// 2. Decrypt payload with CEK
SecretKey secretKey = new SecretKeySpec(cek, "AES");
Cipher aesCipher = Cipher.getInstance("AES/GCM/NoPadding");
aesCipher.init(Cipher.DECRYPT_MODE, secretKey,
new GCMParameterSpec(128, parts.iv()));
byte[] plaintext = aesCipher.doFinal(parts.ciphertext());
// AEADBadTagException thrown if ciphertext was tampered with
return new String(plaintext, StandardCharsets.UTF_8);
}
}
Using Nimbus JOSE + JWT Library (Recommended for Production)
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>9.37.3</version>
</dependency>
// βββ Encrypt (client) ββββββββββββββββββββββββββββββββββββββββββββ
public String encryptWithJwe(Map<String, Object> payload, RSAPublicKey recipientPublicKey)
throws Exception {
JWEHeader header = new JWEHeader.Builder(
JWEAlgorithm.RSA_OAEP_256, // Key wrapping
EncryptionMethod.A256GCM // Content encryption
).keyID("key-2024-01").build();
JWEObject jwe = new JWEObject(header,
new Payload(new JSONObject(payload).toJSONString()));
jwe.encrypt(new RSAEncrypter(recipientPublicKey));
return jwe.serialize(); // 5-part dot-separated string
}
// βββ Decrypt (server) ββββββββββββββββββββββββββββββββββββββββββββ
public Map<String, Object> decryptJwe(String jweString, RSAPrivateKey privateKey)
throws Exception {
JWEObject jwe = JWEObject.parse(jweString);
jwe.decrypt(new RSADecrypter(privateKey));
return jwe.getPayload().toJSONObject();
}
KeyStore vs. TrustStore in Java
In Java and Spring applications, private keys, public certificates, and trusted Certificate Authorities (CAs) are managed using two different storage files: the KeyStore and the TrustStore. Under the hood, both use the same binary file format (usually PKCS12), but they serve entirely opposite roles in authentication.
β Java KeyStore vs. TrustStore
π KeyStore ("Who you are")
Contains your application's private keys and public certificates. Used by a TLS server to prove its identity to visiting clients, or by a client during mutual authentication (mTLS).
- Holds private keys (highly sensitive).
- Used for Server TLS configuration.
- Analogous to your physical Passport/ID card.
JVM System Properties
-Djavax.net.ssl.keyStore=/certs/keystore.p12 -Djavax.net.ssl.keyStorePassword=changeit
Detailed Comparison
| Attribute | KeyStore | TrustStore |
|---|---|---|
| Primary Goal | Prove your identity to others. | Verify the identity of others. |
| Contains | Your Private Keys, Key Pairs, and matching Public Certificates. | Public Certificates of Trusted CAs (e.g., Let's Encrypt, DigiCert) and self-signed certificates. |
| Symmetric/Asymmetric | Contains sensitive private key material. | Contains non-sensitive public certificates. |
| Typical Use Case | TLS Server: Holding the server's private key and certificate to serve HTTPS. TLS Client (mTLS): Holding the client's private key to authenticate to the server. | TLS Client: Verifying that the server's certificate was signed by a trusted CA. TLS Server (mTLS): Verifying that the client's certificate is trusted. |
| Default formats | PKCS12 (recommended, .p12 or .pfx), legacy JKS (.jks). | PKCS12 (recommended, .p12 or .pfx), legacy JKS (.jks). |
| JVM Sizing / System Properties | -Djavax.net.ssl.keyStore-Djavax.net.ssl.keyStorePassword | -Djavax.net.ssl.trustStore-Djavax.net.ssl.trustStorePassword |
Programmatic Loading in Java
Here is how you load a KeyStore/TrustStore programmatically to build custom SSL context handlers (e.g., for custom web clients like RestTemplate or WebClient):
import java.io.InputStream;
import java.security.KeyStore;
import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.TrustManagerFactory;
import javax.net.ssl.SSLContext;
public class SslContextHelper {
public SSLContext createSslContext(String keyStorePath, String keyStorePassword,
String trustStorePath, String trustStorePassword) throws Exception {
// 1. Load the KeyStore (Who we are)
KeyStore keyStore = KeyStore.getInstance("PKCS12");
try (InputStream keyStoreStream = getClass().getClassLoader().getResourceAsStream(keyStorePath)) {
keyStore.load(keyStoreStream, keyStorePassword.toCharArray());
}
KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
kmf.init(keyStore, keyStorePassword.toCharArray());
// 2. Load the TrustStore (Who we trust)
KeyStore trustStore = KeyStore.getInstance("PKCS12");
try (InputStream trustStoreStream = getClass().getClassLoader().getResourceAsStream(trustStorePath)) {
trustStore.load(trustStoreStream, trustStorePassword.toCharArray());
}
TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
tmf.init(trustStore);
// 3. Initialize SSLContext with KeyManagers and TrustManagers
SSLContext sslContext = SSLContext.getInstance("TLSv1.3");
sslContext.init(kmf.getKeyManagers(), tmf.getTrustManagers(), null);
return sslContext;
}
}
CLI Reference: Using JDK keytool
The JDK provides the keytool utility to manage KeyStores and TrustStores.
1. Generate a self-signed key pair (KeyStore)β
Creates a new PKCS12 keystore containing a private key and a self-signed certificate:
keytool -genkeypair \
-alias server-alias \
-keyalg RSA \
-keysize 4096 \
-storetype PKCS12 \
-keystore keystore.p12 \
-validity 365 \
-storepass my-keystore-password
2. Import a public certificate (TrustStore)β
Imports an external certificate authority or partner certificate into your trusted store:
keytool -importcert \
-alias trusted-ca-alias \
-file ca-certificate.crt \
-keystore truststore.p12 \
-storepass my-truststore-password
3. View contents of a storeβ
List all certificates and private keys inside a store file:
keytool -list -v \
-keystore keystore.p12 \
-storepass my-keystore-password
Spring Boot mTLS Configuration (Mutual TLS)
In a mutual TLS configuration, both the client and server must verify each other's identity. Thus, the Spring Boot application configuration requires both a key-store (to send its identity) and a trust-store (to verify the partner's identity):
server:
port: 8443
ssl:
enabled: true
client-auth: need # mTLS active: Server REQUIRES client cert
# Server identity (KeyStore)
key-store: classpath:server-keystore.p12
key-store-password: ${SERVER_KEYSTORE_PASSWORD}
key-store-type: PKCS12
key-alias: server-cert
# Who the server trusts (TrustStore)
trust-store: classpath:server-truststore.p12
trust-store-password: ${SERVER_TRUSTSTORE_PASSWORD}
trust-store-type: PKCS12
TLS β Transport Layer Security
TLS provides confidentiality, integrity, and server authentication for data in transit.
TLS 1.3 Handshake β Step by Step
π TLS 1.3 1-RTT Handshake Sequence
1. Client Hello (Key Share Proposal)
Client initiates connection by sending cipher capabilities and its ephemeral Diffie-Hellman public key share (Client Share).
Handshake Payload
ClientHello - supported_groups: x25519 - key_share: [Client_ECDH_Public]
Key insight: In TLS 1.3, the server certificate is encrypted in transit. Only 1 round-trip needed (vs 2 in TLS 1.2).
What TLS Provides (and Doesn't)
| Property | How | Notes |
|---|---|---|
| Confidentiality | AES-256-GCM session keys | Payload encrypted in transit |
| Integrity | AEAD authentication tag | Detects tampering in transit |
| Server Authentication | X.509 certificate + CA chain | Proves server identity |
| Perfect Forward Secrecy | ECDHE ephemeral keys | Past sessions safe even if private key leaked |
| β Client Authentication | Not by default | Use mTLS or application-level auth |
| β End-to-End | Only to TLS endpoint | Terminated at proxy β use MLE |
| β Data at Rest | Not applicable | Use AES-GCM |
X.509 Certificate Chain of Trust
βοΈ X.509 Certificate Chain of Trust Verifier
1. Inspect Server Certificate
Browser queries domain "*.example.com". Inspects the server's leaf certificate to check expiration date, target hostname, and verify the issuer.
Certificate Attributes
Subject: *.example.com Issuer: Let's Encrypt Intermediate CA
Perfect Forward Secrecy (PFS)
π‘οΈ Perfect Forward Secrecy (PFS) comparison
Plain RSA (Old TLS)
The client generates a session key, encrypts it with the server's long-term public key, and sends it. If an attacker records the encrypted handshake traffic and compromises the server's private key years later, they can decrypt the session key and decode all past logs.
Cryptographic properties
Symmetric Session Key = Client_Generated_Secret If server_private_key leaks: All past traffic decrypted! β
TLS Configuration in Spring Boot
# application.yml
server:
ssl:
enabled: true
key-store: classpath:keystore.p12
key-store-password: ${KEYSTORE_PASSWORD}
key-store-type: PKCS12
key-alias: api-cert
protocol: TLS
enabled-protocols: TLSv1.3,TLSv1.2 # Never TLS 1.0, 1.1
ciphers:
- TLS_AES_256_GCM_SHA384 # TLS 1.3
- TLS_CHACHA20_POLY1305_SHA256 # TLS 1.3
- TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 # TLS 1.2
// Enforce HTTPS via HSTS header
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.headers(headers -> headers
.httpStrictTransportSecurity(hsts -> hsts
.includeSubDomains(true)
.maxAgeInSeconds(31_536_000) // 1 year
.preload(true)
)
);
return http.build();
}
// HTTP β HTTPS redirect
@Bean
public TomcatServletWebServerFactory servletContainer() {
TomcatServletWebServerFactory tomcat = new TomcatServletWebServerFactory() {
@Override
protected void postProcessContext(Context context) {
SecurityConstraint constraint = new SecurityConstraint();
constraint.setUserConstraint("CONFIDENTIAL"); // Force HTTPS
SecurityCollection collection = new SecurityCollection();
collection.addPattern("/*");
constraint.addCollection(collection);
context.addConstraint(constraint);
}
};
tomcat.addAdditionalTomcatConnectors(httpToHttpsRedirectConnector());
return tomcat;
}
Certificate Pinning
Force the client to only trust specific certificates, not any CA-signed cert.
// OkHttp β Android / backend HTTP client
OkHttpClient client = new OkHttpClient.Builder()
.certificatePinner(new CertificatePinner.Builder()
// Pin the SubjectPublicKeyInfo hash of expected cert
.add("api.example.com",
"sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
.add("api.example.com",
"sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=") // Backup pin
.build())
.build();
Certificate pinning can cause app outages if the cert rotates without updating the pin. Always include a backup pin and have a cert rotation plan.
Encryption vs Signing β Decision Chart
π§ Encryption vs. Signing: Decision Chart
π Goal: Hide content (Confidentiality)
If you only need to ensure that unauthorized third parties cannot read the data, use Encryption.
AES-256-GCM). Faster.RSA-OAEP). Slow.Summary: Key Operations Table
| Operation | Key Used | Result | Example |
|---|---|---|---|
| Encrypt | Recipient's public key | Ciphertext only recipient can read | MLE request to server |
| Decrypt | Your private key | Plaintext | Server decrypts MLE request |
| Sign | Your private key | Signature proving your identity | JWT signing, webhook signing |
| Verify signature | Signer's public key | Confirmed authenticity + integrity | Verifying JWT, JWKS |
| TLS server auth | Server's private key signs handshake | Proves server is who it claims | Every HTTPS connection |
Interview Questions
Q1: Explain the difference between encrypting and signing a payload. When would you use each?
- Encryption guarantees confidentiality. You encrypt a payload using the recipient's public key so that only the recipient (who owns the matching private key) can read it. Use case: sending sensitive client data to a payment gateway (Message-Level Encryption).
- Signing guarantees authenticity and integrity. You sign a payload using your private key so that any recipient can verify the signature using your public key, proving the message came from you and has not been altered. Use case: issuing JWT authorization tokens to clients.
Q2: If the server's private key is leaked, what past data is at risk? How does Perfect Forward Secrecy change this?
- Without Forward Secrecy (e.g. RSA key exchange): An attacker who recorded past encrypted network traffic can decrypt all of it retrospectively using the leaked private key.
- With Perfect Forward Secrecy (PFS - e.g. Ephemeral Diffie-Hellman, ECDHE): The server's private key is only used to sign (authenticate) the handshake, not to encrypt the traffic. The actual session keys are generated dynamically for each connection and deleted immediately after. Leaking the private key does not expose past session keys, meaning past recorded traffic remains safe. TLS 1.3 mandates PFS.
Q3: What is JWKS and how does a resource server use it to verify a JWT?
JWKS (JSON Web Key Set) is a JSON document published by the Authorization Server containing a list of its public keys. When a resource server receives a JWT, it:
- Decodes the JWT header and reads the
kid(Key ID) claim.- Fetches the JWKS endpoint (typically caching the result to avoid network round-trips).
- Finds the public key matching the
kid.- Verifies the cryptographic signature of the JWT using that public key.
Q4: What is the kid (Key ID) in a JWT and why is it important for key rotation?
The
kidclaim in the JWT header is an arbitrary identifier indicating which key in the key set was used to sign the token. It is critical for key rotation because it allows the authentication system to support multiple active signing keys simultaneously. Verifiers can look up the matching verification key immediately without having to guess or brute-force signature checks across all keys.
Q5: Explain how zero-downtime JWT key rotation works using JWKS.
To rotate JWT signing keys without causing any verification failures:
- Publish New Key: Generate a new key pair and add the new public key to the JWKS endpoint. The old key remains in the set.
- Switch Signer: Update the authorization server to start signing new JWTs using the new private key (with the new
kid).- Grace Period: Resource servers fetch the new key list when they see the new
kid. Existing tokens signed with the old key remain valid because the old public key is still in the JWKS list.- Retire Old Key: Once all old tokens have expired, safely remove the old key from the JWKS configuration.
Q6: What is JWE (JSON Web Encryption) and how does it differ from JWT (JWS)?
- JWT (normally JWS - JSON Web Signature): The payload is digitally signed. The content is merely Base64url-encoded and is fully readable by anyone who intercepts it. It provides integrity and authenticity, but not confidentiality.
- JWE (JSON Web Encryption): The payload is encrypted using a symmetric encryption key (content encryption key), which is in turn encrypted using the recipient's public key. The payload is unreadable to anyone without the recipient's private key, guaranteeing confidentiality.
Q7: What is Message-Level Encryption and why is it needed if you already have TLS?
TLS provides hop-by-hop transport security (it encrypts data in transit between the client and server). However, once the request terminates at an API gateway, load balancer, or message broker, it is decrypted. Message-Level Encryption (MLE) encrypts the payload itself at the application layer. The data remains encrypted as it travels through intermediate proxies, databases, and logs, and is only decrypted by the final downstream application container, guaranteeing end-to-end data privacy.
Q8: Walk through the TLS 1.3 handshake β what does each step accomplish?
TLS 1.3 reduces the handshake to a single round-trip (1-RTT):
- Client Hello: The client sends cryptographic capabilities, supported cipher suites, and an ephemeral Diffie-Hellman key share (Client Share).
- Server Hello & Handshake: The server selects the cipher suite, generates its own key share (Server Share), computes the shared secret key, and sends its public key share. It also sends its encrypted Certificate and a digital signature (Certificate Verify) proving ownership of the private key.
- Keys & Finished: The client verifies the server certificate and signature, derives the shared secret key, and sends a finished message. Application data transmission starts immediately.
Q9: What is a certificate chain of trust? What happens if an intermediate CA is compromised?
A certificate chain of trust links an end-entity certificate (your domain cert) to a trusted Root CA certificate via one or more intermediate CA certificates:
Root CA CertificateβIntermediate CA CertificateβDomain Certificate. Operating systems and browsers pre-trust Root CAs. When verifying, they walk up the chain verifying signatures. Compromise: If an intermediate CA is compromised, all certificates issued below it become untrusted. The intermediate CA's certificate must be revoked immediately via CRL (Certificate Revocation List) or OCSP, causing browsers to reject any certificates signed by it.
Q10: What is the difference between RS256 and ES256 for JWT signing?
- RS256 (RSA Signature with SHA-256): Uses RSA cryptography. Requires large key sizes (minimum 2048-bit) to be secure, resulting in larger JWT header sizes and slower signature generation speeds.
- ES256 (ECDSA with SHA-256 / P-256 curve): Uses Elliptic Curve cryptography. Provides the same cryptographic strength as a 3072-bit RSA key using only a 256-bit key size. This makes ES256 tokens smaller, faster to compute, and highly efficient for mobile or high-throughput API gateways.
Q11: Why is it unsafe to reuse an IV/nonce when using AES-GCM?
AES-GCM converts the block cipher into a stream cipher using a counter. If the same key and Initialization Vector (IV) are used twice, it generates the exact same key stream. An attacker can XOR the two ciphertexts together, removing the key stream entirely. This reveals the XOR of the plaintexts. If one plaintext is known or easily guessed, the other is completely compromised.
Q12: In hybrid encryption, why encrypt the AES key with RSA instead of using RSA directly?
- Performance: RSA is an asymmetric algorithm based on modular exponentiation of large numbers, which is extremely slow. AES uses fast bitwise operations.
- Size Constraints: RSA can only encrypt data smaller than its key size (e.g. 2048-bit RSA can only encrypt up to 245 bytes). AES has no limit. By encrypting the large payload with AES and encrypting only the small 256-bit AES key with RSA, you get the performance of symmetric encryption alongside the key distribution advantages of asymmetric encryption.
