Practical Application Security for Developers - Part 1: Cryptographic Foundations
Understand hashes, HMAC, AES, and practical cryptographic decisions for backend systems with Java examples.
Practical Application Security for Developers - Part 1: Cryptographic Foundations
As a developer, you don't need to be a cryptographer - but you do need to know which cryptographic tool solves which problem, and how to avoid the handful of mistakes that actually cause real breaches in production.
Use this running example throughout the article: the Order Service asks the Warehouse Service to issue a refund, the Warehouse Service forwards refund.json to the Payment Service, and a single flipped byte changes $999 to $9999. Or worse, an attacker on the network deliberately rewrites the payload. These are the problems cryptography was designed to solve, and this article gives you the practical toolkit to handle them correctly.
We'll cover three foundational primitives - hashing, message authentication, and symmetric encryption - with production-ready Java code you can drop straight into your project.
Why Developers Need to Understand Cryptography
Whether your system is a monolith or a fleet of microservices, it must answer four questions:
| Question | Property | Tool |
|---|---|---|
| Has this data been tampered with? | Integrity | Hash functions |
| Who actually sent this? | Authentication | MAC / Digital signatures |
| Can anyone else read this? | Confidentiality | Encryption |
| Can the sender deny they sent it? | Non-repudiation | Digital signatures |
Each question maps to a specific primitive. By the end of this article, you'll know exactly which one to reach for.
Service Identity in Distributed Systems
When the Payment Service receives a request, how does it know it actually came from the Order Service and not an attacker who found the internal endpoint? Two approaches:
| Approach | How it works | When to use |
|---|---|---|
| API Keys | Each service gets a secret token, included in every request header | Starting out; simple internal services |
| Mutual TLS (mTLS) | Services prove identity via X.509 certificates — like showing passports to each other | Service meshes (Istio); high-security requirements |
Practical advice: Start with API keys in a secrets manager. Graduate to mTLS when you adopt a service mesh or your threat model demands it.
Cryptographic Hash Functions
Back to our refund scenario — we need the Payment Service to detect if refund.json was corrupted or tampered with in transit. This is what hash functions solve.
A cryptographic hash takes any input and produces a fixed-size fingerprint (digest). What makes it cryptographic:
- One-way: You cannot reconstruct the original from the hash
- Collision-resistant: Two different inputs won't produce the same output
- Avalanche effect: A single-bit change in input completely changes the output
Real-world scenario: The Warehouse Service computes SHA-256 of refund.json, sends both the file and the hash. The Payment Service recomputes the hash and compares. If they match — file is intact. If not — reject it.
Limitation: A plain hash doesn't protect against an attacker who can modify both the file and the hash. We'll fix that with MACs below.
The SHA Family
| Algorithm | Output | Status | Use case |
|---|---|---|---|
| SHA-1 | 160-bit | Broken (collision found 2017) | Legacy only — never for security |
| SHA-256 | 256-bit | Secure | Default choice for everything |
| SHA-512 | 512-bit | Secure | When you need extra margin |
| SHA-3 (Keccak) | 256-bit | Secure | Defense-in-depth, compliance |
Rule of thumb: Use SHA-256. Reach for SHA-3 only if compliance specifically requires it or you want a backup that uses a completely different internal design.
Hashing in Java (JCA)
Java's Java Cryptography Architecture (JCA) uses a provider-based model where you request algorithms by name:
import java.io.IOException;
import java.io.InputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
public class HashUtil {
public static String sha3Hex(String resourcePath)
throws IOException, NoSuchAlgorithmException {
try (InputStream in = HashUtil.class.getClassLoader()
.getResourceAsStream(resourcePath)) {
if (in == null) {
throw new IllegalArgumentException(
"Resource not found: " + resourcePath);
}
byte[] digest = MessageDigest.getInstance("SHA3-256")
.digest(in.readAllBytes());
return HexFormat.of().formatHex(digest);
}
}
}Google Tink Alternative
For production systems doing more than just hashing — encryption, signing, key management — consider Google Tink. It makes cryptographic misuse harder by design:
import com.google.crypto.tink.subtle.EngineFactory;
import java.security.MessageDigest;
import java.util.HexFormat;
public class TinkHashUtil {
public static String sha3Hex(byte[] data) throws Exception {
MessageDigest md = EngineFactory.MESSAGE_DIGEST
.getInstance("SHA3-256");
return HexFormat.of().formatHex(md.digest(data));
}
}For plain hashing, the difference is minimal. Where Tink shines is encryption — it forces safe patterns and makes dangerous operations (like ECB mode) simply unavailable.
Message Authentication Codes (MAC)
A plain hash proves data wasn't accidentally changed. But what if you need to prove it wasn't deliberately modified by someone in the middle? The answer: hash the message together with a secret key that only sender and receiver know.
- Hash: Anyone with the message can compute it → proves integrity only
- MAC: Only someone with the secret key can compute it → proves integrity + authenticity
HMAC: The Industry Standard
HMAC (Hash-based MAC, RFC 2104) combines a hash function with a shared secret key. In practice, that means the receiver can check two things at once:
- The payload was not changed in transit
- The sender knew the shared secret when the MAC was created
That is why HMAC shows up in so many systems:
- Stripe webhooks - Stripe signs the payload so your server can reject forged events.
- GitHub webhooks - GitHub signs the request body so your handler knows the payload really came from GitHub.
- AWS Signature Version 4 - AWS signs API requests so services can verify the caller and the request contents.
- JWT HS256 - The issuer signs the token with a shared secret so the verifier can trust the claims.
Java Implementation
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.util.HexFormat;
public class HmacUtil {
public static byte[] hmacSha256(byte[] key, byte[] data) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key, "HmacSHA256"));
return mac.doFinal(data);
}
public static String hmacSha256Hex(byte[] key, byte[] data) throws Exception {
return HexFormat.of().formatHex(hmacSha256(key, data));
}
}Practical Example: Webhook Verification
When a payment provider sends your service a webhook, it signs the payload with your shared secret. Here's how you verify it:
String receivedSig = request.getHeader("X-Signature");
byte[] webhookSecret = System.getenv("WEBHOOK_SECRET").getBytes();
byte[] body = request.getBody();
String computedSig = HmacUtil.hmacSha256Hex(webhookSecret, body);
if (!MessageDigest.isEqual(computedSig.getBytes(), receivedSig.getBytes())) {
throw new SecurityException("Invalid webhook signature — rejecting request");
}Why
MessageDigest.isEqual()instead of.equals()? A regular string comparison returns false at the first differing byte — an attacker can measure response time differences to guess the correct signature byte-by-byte (timing attack).MessageDigest.isEqual()always takes constant time regardless of where the mismatch is.
Symmetric Encryption with AES
Hashing proves integrity. MACs prove integrity + authenticity. But neither hides the data. When you need confidentiality — making sure no one can read what you're sending — you need encryption.
AES (Advanced Encryption Standard) is your go-to symmetric cipher. "Symmetric" means both sides use the same key. It's been the global standard since 2001, implemented in CPU hardware (AES-NI), and has survived two decades of cryptanalysis without a practical break.
AES works on 128-bit (16-byte) blocks. The strategy for handling multiple blocks is the mode of operation — and choosing the right mode is where developers most commonly go wrong.
AES-CBC: Know It, But Avoid It
Cipher Block Chaining was the default for years. Each block is XORed with the previous ciphertext block, requiring a random IV for the first block.
The critical problem: CBC provides confidentiality only, not integrity. An attacker can flip bits in ciphertext and cause predictable changes in decrypted plaintext (bit-flipping attack). The fix is adding HMAC — but the order matters:
| Composition | Security | Example |
|---|---|---|
| Encrypt-then-MAC | Safe — MAC protects ciphertext before decryption | IPsec |
| MAC-then-Encrypt | Vulnerable — led to POODLE, Lucky13 | Old TLS 1.2 |
| Encrypt-and-MAC | Leaks info — MAC computed on plaintext | SSH |
Bottom line: Don't use CBC unless forced by a legacy system. If you must, always Encrypt-then-MAC. Otherwise, just use GCM.
AES-GCM: Your Default Choice
Galois/Counter Mode is authenticated encryption — it encrypts and produces an authentication tag in one operation. Tampered ciphertext = automatic decryption failure. No separate MAC step needed.
Why GCM wins:
- One operation: Confidentiality + integrity + authenticity
- Hardware accelerated: CPU instructions for both AES and GCM polynomial math
- Used everywhere: TLS 1.3, AWS KMS, Google Cloud KMS, Android Keystore
The one rule you must never break: Never reuse a nonce (IV) with the same key. GCM uses a 96-bit nonce. Two messages encrypted with the same key + nonce = attacker recovers both plaintexts by XORing ciphertexts. Always generate a fresh random nonce per encryption.
Java Implementation
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import java.security.SecureRandom;
public class AesGcmUtil {
private static final int GCM_TAG_BITS = 128;
private static final int GCM_NONCE_BYTES = 12;
public static byte[] encrypt(SecretKey key, byte[] plaintext, byte[] aad)
throws Exception {
byte[] nonce = new byte[GCM_NONCE_BYTES];
new SecureRandom().nextBytes(nonce);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, key,
new GCMParameterSpec(GCM_TAG_BITS, nonce));
if (aad != null) cipher.updateAAD(aad);
byte[] ciphertext = cipher.doFinal(plaintext);
// Prepend nonce so the receiver can extract it for decryption
byte[] output = new byte[nonce.length + ciphertext.length];
System.arraycopy(nonce, 0, output, 0, nonce.length);
System.arraycopy(ciphertext, 0, output, nonce.length, ciphertext.length);
return output;
}
public static byte[] decrypt(SecretKey key, byte[] nonceAndCiphertext, byte[] aad)
throws Exception {
byte[] nonce = new byte[GCM_NONCE_BYTES];
System.arraycopy(nonceAndCiphertext, 0, nonce, 0, nonce.length);
byte[] ciphertext = new byte[nonceAndCiphertext.length - nonce.length];
System.arraycopy(nonceAndCiphertext, nonce.length,
ciphertext, 0, ciphertext.length);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, key,
new GCMParameterSpec(GCM_TAG_BITS, nonce));
if (aad != null) cipher.updateAAD(aad);
return cipher.doFinal(ciphertext); // throws AEADBadTagException if tampered
}
}The aad parameter (Additional Authenticated Data) lets you authenticate metadata — like HTTP headers or message type — without encrypting it. The receiver reads it in clear text, but any modification causes decryption to fail.
If decryption throws
AEADBadTagException: The data was tampered with, wrong key was used, or nonce got corrupted. Log it, alert on it, reject the data.
Common Mistakes That Cause Real Breaches
-
Using ECB mode — Encrypts each block independently. Same plaintext block = same ciphertext block, leaking patterns (the famous "ECB penguin"). If your codebase contains
AES/ECB/, it's a bug. -
Reusing GCM nonces — Same key + same nonce on two messages = attacker XORs ciphertexts to recover both plaintexts. Has caused real production breaches.
-
Comparing MACs with
.equals()— String comparison short-circuits at first differing byte. Attackers measure timing differences across thousands of requests to forge valid MACs byte-by-byte. Always use constant-time comparison. -
Rolling your own crypto — Use JCA, Google Tink, or Bouncy Castle. You implement the protocol (which algorithm, how to manage keys), not the primitive.
-
Hardcoding keys in source —
git log --all -S "secretKey"finds this in more repos than anyone wants to admit. Use environment variables at minimum, a secrets manager (Vault, AWS Secrets Manager) in production.
Quick Decision Guide
| Your problem | Tool | Why |
|---|---|---|
| Did this file arrive intact? | SHA-256 | Detects any change to content |
| Did this webhook really come from Stripe? | HMAC-SHA256 | Only holder of shared secret can produce valid MAC |
| Can anyone snoop on this DB column? | AES-256-GCM | Encrypts + detects tampering in one step |
| Legacy system requires AES-CBC | AES-CBC + HMAC (Encrypt-then-MAC) | CBC alone has no integrity protection |
What's Next
We've covered the symmetric world — where both parties share the same key. But this raises an obvious question: how do two services that have never communicated before agree on a shared key without an eavesdropper stealing it?
In Part 2, we'll solve this with public key cryptography - the math that lets two strangers establish trust over an insecure channel. We'll cover RSA, elliptic curves, digital signatures, and the X.509 certificate system that makes HTTPS possible.
Continue Learning
Explore more guides and resources to deepen your knowledge.