Can Your AI Agent See the Card Number? MySQL Dynamic Data Masking for a Bank Support Agent, Measured

 

Tested on: MySQL Enterprise Server 26.7.0 (Commercial), Oracle Linux 9 on OCI | Level: Intermediate

AI agents are getting direct access to enterprise databases. That raises a simple question every bank must answer: just because an AI agent can query the customer table, should it see every card number, email and ID it returns? I built a bank support scenario with 200,000 fictional customers and tested MySQL Dynamic Data Masking on MySQL Enterprise 26.7. The AI agent's account got XXXXXXXXXXXX0042 where a fraud analyst got the real card number, from the same SQL. Filters on masked columns could not be used to guess hidden digits, every attempt by the agent to escape its limits was blocked and recorded by Enterprise Audit, and a single-customer lookup still took 0.000 s.

Architecture  


The flow is the one the MySQL team describes for agentic access: End user → AI application → agent → restricted DB account → MySQL security controls → enterprise data. The agent never connects as a DBA. It uses a service account, ai_support_agent, and three controls inside the server decide what that account can do, what it can see, and what is recorded.

Results at a glance

TestResult
Same query, AI agent vs fraud analystAgent: masked card, email, phone, ID. Analyst with role pii_read: clear values
Same query as root (DBA, no pii_read)Masked
JSON context the agent would send to the LLMNo real card, email, phone or ID
WHERE pan = '<real number>' as the agentEmpty set: filters run on the masked value
Agent tries SET ROLE, UPDATE, copy table, OUTFILE, read mysql.user, DROP POLICYAll blocked (errors 3530, 1142, 1227) and recorded by Enterprise Audit
Full scan, 200,000 rows, 3 masked columns0.059 s without policy, 0.063 s clear, 0.276 s masked
Single customer lookup0.000 s for every identity

The scenario

A customer asks the bank's support assistant: "Why was my card declined?" To answer, the agent needs the customer, the card status and the decline reason. It does not need the full card number, the full email address, the phone number or the national ID. Those values should never reach the LLM prompt, its logs or the chat history.

The lab uses two fictional tables, bank.customers (200,000 rows: name, email, phone, national ID, city, segment) and bank.cards (card number, status, last decline reason), and three identities:

  • ai_support_agent: the agent's service account, role support_ro (SELECT on the two tables only).
  • fraud_analyst: a human investigator, roles support_ro and pii_read.
  • masking_admin: the only non-root account allowed to manage masking policies.

Step 1: enable Dynamic Data Masking

Dynamic Data Masking became generally available in MySQL Enterprise 9.7 LTS. I used the current developer download, 26.7.0, and enabled three components:

-- as an administrative user, from the shell:
--   mysql -uroot -p -D mysql < /usr/share/mysql-26.7/install_component_object_policy.sql
INSTALL COMPONENT 'file://component_masking';
INSTALL COMPONENT 'file://component_masking_functions';

SELECT component_urn FROM mysql.component;
file://component_masking
file://component_masking_functions
file://component_object_policy

The object policy component stores policies in mysql.column_masking_policies. Note the path: on this version the install script lives in /usr/share/mysql-26.7/, not /usr/share/mysql/.

Step 2: roles and least privilege

CREATE ROLE support_ro, pii_read;
GRANT SELECT ON bank.customers TO support_ro;
GRANT SELECT ON bank.cards     TO support_ro;
-- pii_read has no table privileges: it only unlocks clear values

CREATE USER 'ai_support_agent'@'localhost' IDENTIFIED BY '...';
CREATE USER 'fraud_analyst'@'localhost'    IDENTIFIED BY '...';
CREATE USER 'masking_admin'@'localhost'    IDENTIFIED BY '...';
GRANT support_ro           TO 'ai_support_agent'@'localhost';
GRANT support_ro, pii_read TO 'fraud_analyst'@'localhost';
SET DEFAULT ROLE ALL TO 'ai_support_agent'@'localhost', 'fraud_analyst'@'localhost';
GRANT MANAGE_DATA_MASKING_POLICY ON *.* TO 'masking_admin'@'localhost';

Step 3: masking policies

A policy is a CASE expression with a gatekeeper function. Here, only sessions with the pii_read role active see clear values:

CREATE MASKING POLICY mask_pan_pol(p)
CASE WHEN CURRENT_ROLE_IN('pii_read') THEN p ELSE mask_pan(p) END;

CREATE MASKING POLICY mask_email_pol(e)
CASE WHEN CURRENT_ROLE_IN('pii_read') THEN e
     ELSE CONCAT(LEFT(e, 1), '****@', SUBSTRING_INDEX(e, '@', -1)) END;

CREATE MASKING POLICY mask_phone_pol(ph)
CASE WHEN CURRENT_ROLE_IN('pii_read') THEN ph ELSE mask_inner(ph, 4, 2) END;

CREATE MASKING POLICY mask_id_pol(i)
CASE WHEN CURRENT_ROLE_IN('pii_read') THEN i ELSE mask_inner(i, 0, 4) END;

ALTER TABLE bank.customers
  ALTER COLUMN email       SET MASKING POLICY mask_email_pol,
  ALTER COLUMN phone       SET MASKING POLICY mask_phone_pol,
  ALTER COLUMN national_id SET MASKING POLICY mask_id_pol;
ALTER TABLE bank.cards
  ALTER COLUMN pan         SET MASKING POLICY mask_pan_pol;

The policy becomes part of the table definition, so it travels with the column rather than living in application code:

`pan` varchar(19) MASKING POLICY `mask_pan_pol` DEFAULT NULL,

Step 4: one question, three identities

SELECT c.customer_id, c.full_name, c.email, c.phone, c.national_id,
       k.pan, k.card_status, k.last_decline
FROM   bank.customers c JOIN bank.cards k ON k.customer_id = c.customer_id
WHERE  c.customer_id = 42;
-- ai_support_agent (role support_ro)
email              phone            national_id   pan               card_status last_decline
c****@example.com  +91-XXXXXXXXX42  XXXXXXXX0042  XXXXXXXXXXXX0042  BLOCKED     DAILY_LIMIT

-- fraud_analyst (roles pii_read, support_ro)
email                       phone            national_id   pan               card_status last_decline
customer000042@example.com  +91-90000-00042  ID0000000042  4111000000000042  BLOCKED     DAILY_LIMIT

-- root (DBA, no pii_read role active)
email              phone            national_id   pan               card_status last_decline
c****@example.com  +91-XXXXXXXXX42  XXXXXXXX0042  XXXXXXXXXXXX0042  BLOCKED     DAILY_LIMIT

The agent has everything it needs to answer: the card is BLOCKED and the last decline was DAILY_LIMIT. Even root saw masked values, because masking follows the active role, not the account's power. That is useful, but root can still grant itself the role or change policies, which is why the audit layer below matters.

Step 5: what reaches the LLM prompt

Agents usually turn query results into a context block for the model. Built by the agent's own account, that context contains no sensitive values:

{
  "card": "XXXXXXXXXXXX0042",
  "name": "Customer 000042",
  "email": "c****@example.com",
  "phone": "+91-XXXXXXXXX42",
  "card_status": "BLOCKED",
  "customer_id": 42,
  "national_id": "XXXXXXXX0042",
  "last_decline": "DAILY_LIMIT"
}

This is the main benefit for AI applications: sensitive data is removed before it enters the prompt, so it cannot leak through model output, prompt logs, tracing tools or a third-party model provider.

Step 6: is the agent still useful?

  • Lookup by customer ID or name: works.
  • Card status and decline reasons: works, these columns are not masked.
  • Statistics such as GROUP BY last_decline: works.
  • "My card ends in 0042": WHERE pan LIKE '%0042' works because the last four digits survive masking. It returned five customers with cards ending 0042, so the agent must combine it with the customer ID, exactly as a human agent would.

Step 7: what if the AI generates an unexpected query?

LLMs generate SQL you did not write. I tested what a curious or manipulated agent could do with its own account.

Probing hidden values with filters. This was the test I cared about most. If filters ran on the real value, an agent could guess a card number digit by digit with WHERE clauses. They do not:

SELECT customer_id, pan FROM bank.cards WHERE pan = '4111000000000042';        -- Empty set
SELECT COUNT(*) FROM bank.cards WHERE pan LIKE '4111%';                          -- 0
SELECT customer_id FROM bank.customers WHERE email = 'customer000042@example.com'; -- Empty set
SELECT SUBSTRING(pan, 1, 6) FROM bank.cards WHERE customer_id = 42;              -- XXXXXX

Filters and functions see the masked value. That closes the inference route, but it has a practical side effect: an agent that searches by full email or card number silently gets "not found". Design agent tools to search by customer ID, name or last four digits, and treat an empty result as "not visible", not "does not exist".

Escaping its limits. Every attempt failed:

SET ROLE pii_read                       ERROR 3530: `pii_read`@`%` is not granted to `ai_support_agent`@`localhost`
UPDATE bank.cards ...                   ERROR 1142: UPDATE command denied
CREATE TABLE bank.pan_copy AS SELECT    ERROR 1142: INSERT, CREATE command denied
SELECT ... INTO OUTFILE                 ERROR 1227: you need the FILE privilege
SELECT user, host FROM mysql.user       ERROR 1142: SELECT command denied
DROP MASKING POLICY mask_pan_pol        ERROR 1227: you need the MANAGE_DATA_MASKING_POLICY privilege

Step 8: defence in depth with Enterprise Audit

Masking controls what the agent sees. Audit proves what it did. I installed the Enterprise Audit component and logged everything for the agent's account only:

-- mysql -uroot -p -D mysql < /usr/share/mysql-26.7/audit_log_component_filter_install.sql
SELECT audit_log_filter_set_filter('log_agent', '{ "filter": { "log": true } }');
SELECT audit_log_filter_set_user('ai_support_agent@localhost', 'log_agent');

The JSON audit log recorded the normal reads and every blocked attempt, with the error status (trimmed):

"class": "table_access", "event": "read", "table": "customers", "sql_command": "select"
"sql_command": "set_role",            "status": 3530, "query": "SET ROLE pii_read"
"sql_command": "update",              "status": 1142, "query": "UPDATE bank.cards SET card_status = 'ACTIVE' WHERE customer_id = 42"
"sql_command": "drop_masking_policy", "status": 1227, "query": "DROP MASKING POLICY mask_pan_pol"

A spike of non-zero statuses from an agent account is exactly the signal a security team wants: the agent is attempting things outside its workflow, perhaps because of a prompt injection. One caution: the audit log stores the full SQL text. If an agent ever puts a real card number into a WHERE clause, that literal lands in the audit file, so protect and rotate audit logs like any other sensitive data.

Step 9: the cost



A full scan of 200,000 customers reading three masked columns took 0.276 s for the agent, against 0.059 s with no policy, about 0.36 µs per masked value. For a reader with pii_read the policy check itself added very little (0.063 s). A single-customer lookup, which is what a support agent does, took 0.000 s for every identity. Masking cost scales with the number of rows and masked columns returned, which is one more reason to keep agent queries narrow.

Database controls vs application and model controls

LayerWhat it does wellWhat it cannot do
Prompt and model guardrailsSteer behaviour, refuse risky requests, filter outputCannot guarantee anything: a manipulated prompt can bypass them
Application / API layerExpose only approved tools, validate inputs, rate limitsEvery new tool or code path must repeat the rules
Roles and least privilegeDecide which tables and actions the agent can use at allCannot hide individual columns' values from a permitted SELECT
Dynamic Data MaskingEnforced in the server for every query, including SQL the AI inventsDoes not stop the agent reading rows it is allowed to read; masking rules must fit the business
Enterprise AuditEvidence of every query and every blocked attemptDetects after the fact; logs need protection themselves

Recommendations for AI applications on enterprise data

  • Give every agent its own database account. Never reuse an application or DBA account. Masking and audit both depend on knowing who is asking.
  • Grant clear access through a role such as pii_read, never by default. Masking follows the active role.
  • Mask by default, unmask by exception. Keep the last four digits or the email domain when they help the task.
  • Design agent tools around IDs, not secrets. Filters see masked values, so searching by full card number or email will not work for the agent.
  • Separate policy administration. Grant MANAGE_DATA_MASKING_POLICY to a dedicated account, not to the agent or the application.
  • Audit agent accounts and alert on non-zero statuses such as 1142, 1227 and 3530.
  • Remember what masking is not: it does not encrypt storage, limit which rows an agent reads, or replace network security. Combine it with encryption at rest, row-level restrictions where needed, and options such as Enterprise Firewall or MySQL REST Service for a narrower interface.

Summary

MySQL Dynamic Data Masking let a bank support AI agent do its job, finding the customer, the card status and the decline reason, while card numbers, emails, phones and IDs stayed masked in every result and in the LLM prompt. Because masking runs inside the server, it applied to SQL the AI generated on its own, and filters could not be used to probe hidden digits. Roles kept the agent read-only, every attempt to escape was blocked, and Enterprise Audit recorded it all. Masking cost about 0.36 µs per value on a full scan and nothing measurable on a single lookup. Database-level controls do not replace application guardrails, but they are the layer an AI cannot talk its way around.

References: Oracle MySQL blog posts "Dynamic Data Masking in MySQL: Protect Sensitive Data Without App Changes" and "Dynamic Data Masking (DDM) with MySQL Enterprise Edition 9.7: Reduce your sensitive data exposure", and the MySQL Reference Manual chapters on data masking, dynamic masking policies and gatekeeper functions.

Lab design, testing and all results are my own, run on an Oracle LiveLabs sandbox with fictional data. 

Tamilselvan Perumal is an Oracle ACE Associate and Senior DBA with 12+ years in banking databases, working on database performance and benchmarking. Connect on LinkedIn.