security

Practical Application Security for Developers - Part 1: Cryptographic Foundations

Understand hashes, HMAC, AES, and practical cryptographic decisions for backend systems with Java examples.

Reading Time: 12 min readAuthor: DeepTechHub
#security#appsec#cryptography#hmac#aes
Practical Application Security for Developers - Part 1: Cryptographic Foundations

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.

data-integrity-attack
Fig: Data integrity attack

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:

  1. Has this data been modified?
  2. Who sent this message or request?
  3. Can anyone else read this data?
  4. Can the sender later deny sending it?

Cryptography provides a specific mechanism for each of these problems:

Security QuestionSecurity PropertyCommon Solution
Has this data been modified?IntegrityHash functions
Who sent this message?AuthenticationMACs and Digital Signatures
Can anyone else read this data?ConfidentialityEncryption
Can the sender deny sending it?Non-repudiationDigital 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:

ApproachHow it worksBest suited for
API KeysEach service is assigned a secret token that is included in request headersSmaller systems and straightforward internal service communication
Mutual TLS (mTLS)Services authenticate each other using X.509 certificatesService 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.

AlgorithmOutputStatusUse case
SHA-1160-bitBroken (collision found 2017)Legacy only — never for security
SHA-256256-bitSecureDefault choice for everything
SHA-512512-bitSecureWhen you need extra margin
SHA-3 (Keccak)256-bitSecureDefense-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.

data-integrity-attack
Fig: Java Cryptography Architecture

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:

  1. The payload was not changed in transit
  2. 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.

Symmetric encryption
Fig: Symmetric encryption workflow

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:

CompositionSecurityExample
Encrypt-then-MACRecommended — ciphertext is verified before decryptionIPsec
MAC-then-EncryptVulnerable to several attacks, including POODLE and Lucky13Legacy TLS implementations
Encrypt-and-MACCan expose additional information and is generally discouragedSSH

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

  1. 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.

  2. Reusing GCM nonces — Same key + same nonce on two messages = attacker XORs ciphertexts to recover both plaintexts. Has caused real production breaches.

  3. 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.

  4. Rolling your own crypto — Use JCA, Google Tink, or Bouncy Castle. You implement the protocol (which algorithm, how to manage keys), not the primitive.

  5. Hardcoding keys in sourcegit 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?