
Encryption at rest is a key and a product setting. It is not a substitute for who can run SELECT.
PCI DSS now says disk or partition encryption alone is not enough for stored account data. Application-level encryption, key custody, and access logs are the rest of the story.
The usual mistake is ticking the RDS encryption box and leaving a DBA login that can read every row in cleartext in the console.
This page is which layer of encryption solves which theft, and the compliance line that keeps getting misread as ‘the disk is enough.’
PCI DSS 4.0.1 went live on 31 March 2025. Requirement 3.5.1.2 says disk or partition encryption alone no longer renders a stored PAN unreadable on a server disk. A volume that unlocks when mysqld starts is a stolen-media control. It is not a dump control.
The control is a layer that matches a failure you can name, a key that does not sit next to the ciphertext, and a proof that a dump of appdb does not print a PAN.
Name the theft before the cipher. A retired drive of cards.ibd dies on TDE if the keyring stayed home. A mysqldump of the same table walks past that lock and only dies if the app already wrapped the PAN.
SecureCoding
Two places, two different locks
CWE-311 is missing encryption of sensitive data. The CWE does not say which layer. Three places show up in every web stack, and they fail independently.
- In transit. Bytes on a socket. TLS 1.3 on the public 443, and TLS on 3306 if the app is on another host. A packet capture without the session keys should print noise. That is the TLS page.
- At rest on media.
.ibdfiles, redo, undo, a snapshot, a drive in a courier bag. TDE and volume crypto close this. They unlock when the process starts with the keyring present. - At rest as rows. A logical dump, a replica you did not mean to give away, a support engineer running SELECT. Only a field the app encrypts before INSERT stays closed here.
Do not treat HTTPS on the browser origin as a substitute for the other two. HSTS never reaches 3306. Do not treat ENCRYPTION='Y' as a substitute for the third. mysqld decrypts pages before it answers SQL.
| Layer | Closes | Still open |
|---|---|---|
| TLS on the socket | A tap on the hop | Files on disk, a SELECT |
| InnoDB TDE | Stolen .ibd, a dead drive | Any login that can SELECT |
| App GCM on a column | A dump of that column | Need that value in a WHERE |
What TDE actually covers
MySQL 8.4 InnoDB data-at-rest chapter on 22 August 2026. InnoDB uses a two-tier key. A tablespace key lives in the tablespace header, wrapped by a master key. The master key lives in a keyring. Rotate the master with ALTER INSTANCE ROTATE INNODB MASTER KEY. The tablespace key itself does not change. AES-CBC encrypts page data. AES-ECB wraps the tablespace key. Those modes are the product, not a choice you make in the app.
What a stolen file no longer gives you: readable pages from appdb/cards.ibd, encrypted redo, encrypted undo, once those flags are on. What a stolen file still gives you if the keyring file rode along: everything. The 8.4 chapter says do not put the keyring data file in the same directory as the tablespace files. Do that. Back the keyring up on a different path, on a different host, or in a vault.
What TDE never covers: a process that already has the master key in memory. That is every running mysqld after a clean start. A SELECT from webapp returns plaintext. A mysqldump of cards returns plaintext. Replication sends plaintext unless you also encrypt that channel. TDE is media. Say media when you turn it on.
MySQL 8.4 keyring, then ENCRYPTION=’Y’
MySQL 8.4.0 shipped on 30 April 2024 as the LTS line. The 8.0.24-era keyring plugins keyring_file, keyring_encrypted_file, and keyring_oci are gone. The replacement is a component loaded from a manifest before InnoDB starts. Load it late and encrypted tablespaces will not open.
Community ships component_keyring_file. The 8.4 keyring chapter says that file component is not a PCI or FIPS answer. PCI wants a vault or an HSM. Use the file component on a laptop lab. Use HashiCorp Vault, AWS KMS, or Oracle Key Vault on a cardholder store.
# /usr/sbin/mysqld.my next to the mysqld binary
{
"read_local_manifest": false,
"components": "file://component_keyring_file"
}
# /usr/lib64/mysql/plugin/component_keyring_file.cnf
{
"path": "/var/lib/mysql-keyring/keyring",
"read_only": false
}
Identifiers stay component_keyring_file, appdb, and cards. Put /var/lib/mysql-keyring on a volume that is not the datadir. Then turn encryption on for the schema you care about, and for the system tablespace if the dictionary must follow.
SET GLOBAL default_table_encryption = ON;
CREATE SCHEMA appdb DEFAULT ENCRYPTION = 'Y';
ALTER TABLESPACE mysql ENCRYPTION = 'Y';
CREATE TABLE appdb.cards (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
org_id BIGINT UNSIGNED NOT NULL,
last4 CHAR(4) NOT NULL,
pan_ct VARBINARY(64) NOT NULL,
pan_iv BINARY(12) NOT NULL,
pan_tag BINARY(16) NOT NULL,
dek_wrap VARBINARY(80) NOT NULL,
PRIMARY KEY (id),
KEY org_id (org_id)
) ENCRYPTION = 'Y';
Redo and undo have their own flags, innodb_redo_log_encrypt and innodb_undo_log_encrypt. Turn them on after the keyring is loadable at boot. A restart without the component will not recover encrypted redo. That is the 8.4 prerequisite section. Keep a tested restore of the keyring before the first production ALTER.
Prove the flag from the dictionary, not from a comment in a ticket:
SELECT SPACE, NAME, ENCRYPTION
FROM INFORMATION_SCHEMA.INNODB_TABLESPACES
WHERE NAME LIKE 'appdb/%' OR NAME = 'mysql';
-- Expect ENCRYPTION = Y
Column crypto the app owns
A PAN, a national id, a backup code: values the database should store and should not read. Encrypt in the process that already has a reason to see the plaintext. Store pan_ct, pan_iv, pan_tag, and a wrapped dek. Store last4 in the clear if the UI must show it. Never store the full PAN in a second column “for search.”
Node’s node:crypto ships AES-256-GCM. Twelve-byte IV. Sixteen-byte auth tag. A 32-byte dek from randomBytes. Wrap that dek with a KEK you load from the environment on a laptop, or from a vault in production. Identifiers stay dek, FIELD_KEK, encryptPan, and decryptPan.
const crypto = require("node:crypto");
const ALGO = "aes-256-gcm";
function loadKek() {
const b64 = process.env.FIELD_KEK;
if (!b64) throw new Error("FIELD_KEK missing");
const kek = Buffer.from(b64, "base64");
if (kek.length !== 32) throw new Error("FIELD_KEK must be 32 bytes");
return kek;
}
function wrapDek(dek, kek) {
const iv = crypto.randomBytes(12);
const c = crypto.createCipheriv(ALGO, kek, iv);
const ct = Buffer.concat([c.update(dek), c.final()]);
return Buffer.concat([iv, c.getAuthTag(), ct]);
}
function unwrapDek(dekWrap, kek) {
const iv = dekWrap.subarray(0, 12);
const tag = dekWrap.subarray(12, 28);
const ct = dekWrap.subarray(28);
const d = crypto.createDecipheriv(ALGO, kek, iv);
d.setAuthTag(tag);
return Buffer.concat([d.update(ct), d.final()]);
}
function encryptPan(pan, kek) {
const dek = crypto.randomBytes(32);
const iv = crypto.randomBytes(12);
const c = crypto.createCipheriv(ALGO, dek, iv);
const pan_ct = Buffer.concat([c.update(pan, "utf8"), c.final()]);
return { pan_ct, pan_iv: iv, pan_tag: c.getAuthTag(), dek_wrap: wrapDek(dek, kek) };
}
function decryptPan({ pan_ct, pan_iv, pan_tag, dek_wrap }, kek) {
const dek = unwrapDek(dek_wrap, kek);
const d = crypto.createDecipheriv(ALGO, dek, pan_iv);
d.setAuthTag(pan_tag);
return Buffer.concat([d.update(pan_ct), d.final()]).toString("utf8");
}
If KMS or FIELD_KEK is missing, refuse the write. Do not fall back to storing the PAN in the clear. A named fallback that keeps the site “up” is how a vault outage becomes a compliance outage you discover later.
async function saveCard(pool, orgId, pan) {
const kek = loadKek();
const last4 = pan.slice(-4);
const row = encryptPan(pan, kek);
await pool.execute(
`INSERT INTO cards (org_id, last4, pan_ct, pan_iv, pan_tag, dek_wrap)
VALUES (?, ?, ?, ?, ?, ?)`,
[orgId, last4, row.pan_ct, row.pan_iv, row.pan_tag, row.dek_wrap],
);
}
You lose native equality on the ciphertext. That is the point SigmundA named. Hash a lookup token if you must find a card by PAN: HMAC-SHA-256 under a second key, store that in pan_lookup, compare the HMAC, never the PAN. I am not walking a searchable-encryption scheme. If you need rich WHERE on the secret, you do not have a secret you can keep from mysqld.
Key custody is the whole game
Three objects, three homes.
dek. Per row or per tenant. Lives wrapped indek_wrapnext to the ciphertext. Never logged.FIELD_KEK. Wrapsdek. Lives in a vault. The app fetches it at boot or per request. Rotation means re-wrap, not a rewrite of every PAN, if you keepdekas the data key.- InnoDB master key. Lives in the keyring. Rotates with
ALTER INSTANCE ROTATE INNODB MASTER KEY. NeedsENCRYPTION_KEY_ADMIN. Back it up before the first rotate.
Do not commit any of the three. Do not put FIELD_KEK in docker-compose.yml. Do not leave component_keyring_file‘s path on a backup that also holds datadir. A backup that contains both the .ibd and the keyring is a plaintext backup with extra steps.
The secure coding checklist is the rest of secret handling in the app. This page is the split: media key versus field key. A JWT signing secret is a different object. Do not reuse FIELD_KEK as JWT_SECRET.
Who may decrypt is still an object check. decryptPan in a request that already failed the tenant predicate is a decrypted PAN in a log you did not mean to write. Encrypting the column does not replace the WHERE on org_id.
TLS to the database is not optional
TDE and GCM do not close a tap on 3306. The MySQL lock page already sets require_secure_transport=ON and a pool that verifies the CA. Repeat the client half here so the identifiers match this article.
const mysql = require("mysql2/promise");
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: "webapp",
database: "appdb",
password: process.env.DB_PASSWORD,
ssl: {
ca: process.env.DB_SSL_CA,
rejectUnauthorized: true,
minVersion: "TLSv1.2",
},
});
ssl: true with rejectUnauthorized: false is a handshake that still accepts any certificate. That is not verify. The TLS guide is the listener bar for 443. The same verify rule applies to 3306. Unix socket on the same host may skip TLS. A remote webapp login may not.
I have not confirmed every managed MySQL vendor’s default on 22 August 2026. Read that vendor’s CA bundle page, put the bundle in DB_SSL_CA, then run SHOW SESSION STATUS LIKE 'Ssl_version' from the app user. Expect 1.2 or 1.3. Expect the server to refuse a client that omits SSL when require_secure_transport is ON.
Prove the file lock and the field lock
You are not attacking a stranger’s database. You are proving your own appdb will not open without the keyring, and that a dump of cards does not print a PAN.
- Stop mysqld. Move the keyring file aside. Start it. Encrypted tablespaces must fail to open. Put the file back. Start. They must open.
INFORMATION_SCHEMA.INNODB_TABLESPACESshowsENCRYPTION='Y'forappdb/cardsand formysqlif you altered it.- Insert one card through
saveCard.SELECT pan_ct, last4 FROM cardsaswebappmust show bytes and four digits, not the PAN. - A connection from the app host that omits SSL must fail if the app is remote and
require_secure_transportis ON.
# on a host you admin
mysql -u webapp -e "SELECT SPACE, NAME, ENCRYPTION
FROM INFORMATION_SCHEMA.INNODB_TABLESPACES
WHERE NAME LIKE 'appdb/%'"
mysql -u webapp appdb -e "SELECT last4, TO_BASE64(pan_ct) FROM cards LIMIT 1"
# Expect last4 only. pan_ct is not a 16-digit PAN.
# Expect this to fail against a remote host with require_secure_transport=ON
mysql -u webapp -h "$DB_HOST" --ssl-mode=DISABLED -e "SELECT 1"
// staging test against your app
const { encryptPan, decryptPan, loadKek } = require("./panCrypto");
test("round trip, dump is not the PAN", () => {
const kek = loadKek();
const pan = "4111111111111111";
const row = encryptPan(pan, kek);
expect(row.pan_ct.includes("4111")).toBe(false);
expect(decryptPan(row, kek)).toBe(pan);
});
Grep the hatches this page named:
rg -n "AES_ENCRYPT|AES_DECRYPT|rejectUnauthorized:\\s*false|FIELD_KEK\\s*=" \
--glob '!node_modules'
A hit on AES_ENCRYPT is a leftover MySQL helper, not app-level encryption. A hit on rejectUnauthorized: false is an unverified hop. A FIELD_KEK= in a committed file is a leaked KEK. Fix those three before you argue about AES-CBC versus GCM on the pages.
Questions we keep getting
Does volume encryption replace InnoDB TDE?
It closes a stolen disk the same way. It still unlocks for a running host. PCI 4.0.1 still wants a field or file lock for stored PAN on non-removable media. Keep the volume lock. Add TDE or app GCM on top for the row store.
Can I encrypt every column in the app?
You can. You then lose SQL on those columns. Encrypt the values a dump would hurt. Leave the keys you filter on in the clear, behind a tenant predicate. A table of only ciphertext is a blob store.
Where does a password hash sit in this split?
A password is not a PAN you decrypt. Store a slow hash. Do not AES-GCM it so you can “email them their password.” That is a different page and a different mistake.



