Practical Application Security for Developers - Part 1: Cryptographic Foundations
Understand hashes, HMAC, AES, and practical cryptographic decisions for backend systems with Java examples.
Welcome to the Practical Application Security for Developers series. Across seven parts, we'll explore the security concepts and practices that developers encounter when building modern applications, APIs, and distributed systems.
We begin with cryptography because many application security controls ultimately rely on it. Yet most security vulnerabilities aren't caused by weak cryptographic algorithms—they result from using the wrong cryptographic tool, misunderstanding its guarantees, or applying it incorrectly.
In this first part, you'll learn the fundamental security properties provided by cryptography and how to choose the right primitive for the problem you're trying to solve.
Consider a simple microservice flow which consists of following 3 services:
- Order Service
- Warehouse Service
- Payment Service.
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.
Why Developers Need to Understand Cryptography
Whether you're building a monolith, a microservice-based application, or a distributed system, every application must answer four fundamental security questions:
- Has this data been modified?
- Who sent this message or request?
- Can anyone else read this data?
- Can the sender later deny sending it?
Cryptography provides a specific mechanism for each of these problems:
| Security Question | Security Property | Common Solution |
|---|---|---|
| Has this data been modified? | Integrity | Hash functions |
| Who sent this message? | Authentication | MACs and Digital Signatures |
| Can anyone else read this data? | Confidentiality | Encryption |
| Can the sender deny sending it? | Non-repudiation | Digital Signatures |
A common mistake is assuming that one cryptographic tool solves all security problems. In reality, each security requirement maps to a different cryptographic primitive. Understanding which tool solves which problem is the key to building secure applications.
By the end of this article, you'll know exactly when to use hashes, MACs, digital signatures, and encryption.
Service Identity in Distributed Systems
In a distributed system, services need a way to verify the identity of other services. In the above example, when the Payment Service receives a request, it should be able to confirm that the request genuinely originated from the Order Service and not from an attacker who discovered an internal endpoint.
Common approaches include:
| Approach | How it works | Best suited for |
|---|---|---|
| API Keys | Each service is assigned a secret token that is included in request headers | Smaller systems and straightforward internal service communication |
| Mutual TLS (mTLS) | Services authenticate each other using X.509 certificates | Service meshes (e.g., Istio) and environments with stronger security requirements |
Recommendation: Start with API keys stored in a secure secrets manager. As your architecture grows or your security requirements increase, consider adopting mTLS—especially when introducing a service mesh.
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
The Secure Hash Algorithm (SHA) family contains widely used cryptographic hash functions.
A SHA algorithm converts input data of any size into a fixed-size output called a hash or digest. Even a tiny change in the input produces a completely different result.
Two properties make SHA useful in security:
- One-way: deriving the original input from the hash should be impractical.
- Collision resistance: two different inputs should not generate the same hash.
In practice, SHA algorithms are commonly used for integrity verification, digital signatures, certificates, and HMAC.
| 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 provides cryptographic functionality through the Java Cryptography Architecture (JCA). Rather than working directly with cryptographic implementations, developers request algorithms by name and let the configured security provider supply the implementation. Below figure shows the components involved in JCA.

This provider-based design makes applications portable and allows cryptographic implementations to be replaced without changing application code.
For example, to create a SHA-256 hash, you simply request the algorithm 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.
AES: Symmetric Encryption
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 (CBC) was the standard mode of operation for symmetric encryption for many years. While it provides confidentiality, it does not provide integrity or authenticity.
In CBC mode, each plaintext block is combined with the previous ciphertext block during encryption, and a random Initialization Vector (IV) is required for the first block.
The main security concern is that attackers can manipulate ciphertext and cause predictable changes in the decrypted plaintext, a technique known as a bit-flipping attack. To use CBC securely, you must add a separate integrity check, typically using an HMAC.
However, the way encryption and authentication are combined is critical:
| Composition | Security | Example |
|---|---|---|
| Encrypt-then-MAC | Recommended — ciphertext is verified before decryption | IPsec |
| MAC-then-Encrypt | Vulnerable to several attacks, including POODLE and Lucky13 | Legacy TLS implementations |
| Encrypt-and-MAC | Can expose additional information and is generally discouraged | 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
- If you need to detect changes to data, use SHA-256.
- If you need to verify that a message came from a trusted sender, use HMAC-SHA256.
- If you need to encrypt data and detect tampering, use AES-256-GCM.
- If you're stuck with AES-CBC, always pair it with HMAC (Encrypt-then-MAC).
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.
Did you find this article useful?