You are currently viewing Encrypting Small Values in Java with AES-GCM

Encrypting Small Values in Java with AES-GCM

A Java application can encrypt a customer note correctly and still expose it if the key sits in the same database row. The key, ciphertext, and metadata needed to decrypt the note need separate places in the design. For new application-level encryption of small values, AES-GCM is a practical choice: it keeps the data confidential and detects changes to it.

Decide what encryption needs to protect

Trace the data first. Which fields are sensitive? Where are they stored? Which service needs to decrypt them, and who can access the storage and the key? Keeping keys separate can limit the damage from a database-only disclosure. It cannot protect plaintext while an authorized application is using it, or stop an attacker who controls that application and its keys.

Use TLS for data in transit and appropriate storage controls too; neither replaces field encryption. Passwords need a different approach: store them with a suitable password-hashing scheme, not reversible AES. Encryption belongs where the application must recover the original value, such as a private document or an external-service credential.

Developer checks how encrypted fields are stored

Build around authenticated encryption

Java's Cryptography Architecture exposes encryption through Cipher. Request AES/GCM/NoPadding explicitly. A provider default such as AES may select an unsuitable mode. GCM produces ciphertext and an authentication tag; if the tag fails to verify during decryption, reject the data. Do not return partial plaintext or treat the failure as an empty value.

Key, nonce, and record format

  • Key: Generate an AES key with a cryptographically secure generator. A 256-bit key is a common choice on current Java runtimes. Do not use a human password directly as AES key bytes. If a password must supply the key, use a password-based key derivation function with a unique salt and a deliberate work factor.
  • Nonce: GCM needs a fresh nonce for every encryption under the same key. The example below uses a 12-byte nonce generated with SecureRandom. Never reuse a nonce-key pair; reuse can seriously compromise protection.
  • Tag: A 128-bit authentication tag detects changes to the data. Java's GCM cipher appends the tag to the ciphertext returned by doFinal.
  • Envelope: Store a format version, key identifier, nonce, and ciphertext-with-tag. The nonce is not secret. The key identifier tells the application which separately stored key to use.

These methods show the core operation with a caller-supplied key. They leave key storage, record serialization, and version selection to the application:

import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;

class FieldCrypto {
    private static final SecureRandom RANDOM = new SecureRandom();
    private static final int NONCE_BYTES = 12;
    private static final int TAG_BITS = 128;

    static SecretKey newKey() throws Exception {
        KeyGenerator generator = KeyGenerator.getInstance("AES");
        generator.init(256, RANDOM);
        return generator.generateKey();
    }

    static byte[] encrypt(String value, SecretKey key, byte[] nonce)
            throws Exception {
        if (nonce.length != NONCE_BYTES) {
            throw new IllegalArgumentException("Invalid nonce length");
        }
        Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
        cipher.init(Cipher.ENCRYPT_MODE, key,
                new GCMParameterSpec(TAG_BITS, nonce));
        return cipher.doFinal(value.getBytes(StandardCharsets.UTF_8));
    }

    static String decrypt(byte[] encrypted, SecretKey key, byte[] nonce)
            throws Exception {
        if (nonce.length != NONCE_BYTES) {
            throw new IllegalArgumentException("Invalid nonce length");
        }
        Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
        cipher.init(Cipher.DECRYPT_MODE, key,
                new GCMParameterSpec(TAG_BITS, nonce));
        byte[] plain = cipher.doFinal(encrypted);
        return new String(plain, StandardCharsets.UTF_8);
    }

    static byte[] freshNonce() {
        byte[] nonce = new byte[NONCE_BYTES];
        RANDOM.nextBytes(nonce);
        return nonce;
    }
}

Call freshNonce() once per encryption and store that nonce beside the returned bytes. Decryption needs the original nonce, not a new one. Accepting a caller-supplied nonce makes it possible to persist the value, but also leaves reuse prevention to the caller. If a system performs very large numbers of encryptions under one key, plan nonce generation and key limits rather than assuming random selection will remain sufficient indefinitely.

Keep keys outside the protected data store

Generating a key at startup with newKey() works for a temporary test. Unless you retain that key, though, the data becomes unreadable after a restart. Production applications typically obtain keys through a managed key service or another access-controlled secret-management system. Keep key bytes out of source code, configuration committed to version control, logs, and the same database backup as the ciphertext.

Limit which processes can request decryption, audit key access, and plan rotation before storing long-lived records. A key identifier lets new records use a new key while older records remain decryptable during migration. Delete an old key before re-encrypting its records, and those records become inaccessible. Back up keys securely and test recovery with non-production data.

Key access is separated from stored records

Bind ciphertext to its context

Even valid ciphertext could be copied into the wrong record if the application accepts it there. GCM supports additional authenticated data (AAD): unencrypted bytes covered by the authentication tag. To bind a field to a stable record ID and field name, call cipher.updateAAD(contextBytes) after init and before doFinal on both encryption and decryption. The bytes must match exactly.

Choose context that will not change unexpectedly. A user-facing name that can be edited is a poor AAD value unless the application re-encrypts the field on every edit. Set one encoding for AAD, such as UTF-8, and separate its components unambiguously so different ID-and-field combinations cannot produce the same bytes.

Test failures, not just round trips

An encrypt-then-decrypt test covers only the happy path. Also flip a ciphertext byte, change the nonce, use the wrong key, and supply mismatched AAD. Each case should fail authentication without exposing plaintext. Test empty strings and Unicode text, and check that serialization preserves every nonce and ciphertext byte.

Keep failure messages useful without logging sensitive inputs or decrypted values. A tag failure may indicate corruption, a wrong key, or tampering; it usually cannot tell you which. For an integration test, encrypt a test record and restart the application. Load its saved envelope and key by identifier, confirm it decrypts, then change one ciphertext byte and confirm decryption rejects it.