Skip to content

AxonOps — AI-Native Control Plane for Open Source Data Platforms

Cassandra CQL Dynamic Data Masking

Dynamic Data Masking (DDM) allows sensitive column data to be automatically obfuscated when read by users without specific permissions, while storing the original data intact. This enables fine-grained access control where different users see different representations of the same data based on their roles.


Dynamic Data Masking transforms column values at read time based on user permissions:

Dynamic Data Masking FlowDynamic Data Masking FlowTable: usersPrivileged User(has UNMASK)Regular User(no UNMASK)email: alice@example.comssn: 123-45-6789phone: 555-123-4567Sees:email: alice@example.comssn: 123-45-6789phone: 555-123-4567Sees:email: ****ssn:*--6789phone: 555-*-**Same query, different results based on permissionsOriginal data remains unchanged in storageSELECTSELECT
AspectDescription
StorageOriginal data stored unchanged
TransformationMasking applied at read time only
ControlPer-column masking functions
AccessPermissions determine who sees what
BenefitDescription
Data protectionSensitive data hidden from unauthorized users
Compliance supportHelps meet GDPR, HIPAA, PCI-DSS requirements
Application transparencyNo application code changes required
Flexible access controlDifferent users see different data views
Original data preservedFull access available to authorized users

  • Masking functions are applied at read time on the coordinator node
  • Original data is never modified; masking is purely a read transformation
  • Users with UNMASK permission see original unmasked values
  • Users with SELECT_MASKED permission see masked values
  • Masking is applied before results are returned to the client
  • ALTER TABLE can add or remove masking from existing columns

Undefined Behavior

The following behaviors are undefined and must not be relied upon:

  • Materialized view masking: Views may expose unmasked data depending on permissions at view creation
  • Index leakage: Secondary indexes may allow inference of masked values through query patterns
  • UDF security: User-defined masking functions may have implementation vulnerabilities
  • Prepared statement caching: Application-level caching of query results may bypass masking if not invalidated on permission changes
  • CDC and backup exposure: Change Data Capture and backup files contain unmasked data
PermissionCan SELECTSees MaskedSees Unmasked
Neither❌ No--
SELECT only❌ No--
SELECT_MASKED✅ Yes✅ Yes❌ No
UNMASK✅ Yes-✅ Yes
Both✅ Yes-✅ Yes
FunctionInputOutputNotes
mask_defaultAnyType-specific maskReturns **** for text, 0 for numbers
mask_nullAnynullAlways returns null
mask_replaceTextReplacementReplaces with specified character
mask_innerTextPartial maskMasks middle characters
mask_outerTextPartial maskMasks outer characters
Custom UDFAnyAnyUser-defined transformation
Failure ModeOutcomeClient Action
Missing SELECT_MASKED or UNMASKQuery deniedRequest appropriate permission
Masking function failsQuery failsFix masking function
Custom UDF throws exceptionQuery fails with masked column errorFix UDF implementation
Column type mismatchQuery may fail or return unexpected resultsEnsure function matches column type
VersionBehavior
5.0+Dynamic Data Masking introduced (CEP-20, CASSANDRA-17940)

Dynamic Data Masking was proposed through the Cassandra Enhancement Proposal (CEP) process and implemented as a major security feature.

MilestoneReferenceDescription
CEP-20CEP-20: Dynamic Data MaskingDesign proposal defining DDM architecture
CASSANDRA-17940CASSANDRA-17940Main implementation ticket
ReleaseCassandra 5.0First version with DDM support

The CEP-20 proposal established several requirements:

  1. Column-level granularity - Masking defined per column, not per table
  2. Native functions - Built-in masking functions for common patterns
  3. Custom functions - User-defined functions (UDFs) for specialized masking
  4. Permission-based - UNMASK permission controls access to original data
  5. Backward compatible - Existing applications work without modification

DDM in Cassandra follows patterns established in other databases:

DatabaseFeature NameAvailable Since
SQL ServerDynamic Data Masking2016
OracleData Redaction12c
PostgreSQLData Masking ExtensionCommunity extension
MySQLEnterprise Data Masking8.0 Enterprise
CassandraDynamic Data Masking5.0

DDM is disabled by default and must be enabled in cassandra.yaml:

# Enable dynamic data masking (default: false)
dynamic_data_masking_enabled: true

Configuration Requirement

DDM must be explicitly enabled. Without this setting, masking functions cannot be attached to columns and UNMASK/SELECT_MASKED permissions have no effect.

Custom masking functions require UDFs to be enabled:

# Required for custom masking functions
user_defined_functions_enabled: true
# Optional: allow Java UDFs (more powerful but higher security risk)
user_defined_functions_threads_enabled: true

Configuration changes require a rolling restart of all nodes in the cluster.


Cassandra provides six built-in masking functions for common data protection scenarios.

Replaces the column value with null.

Signature:

mask_null(value)

Use cases:

  • Complete data hiding
  • Columns that should be invisible to regular users

Example:

CREATE TABLE users (
user_id uuid PRIMARY KEY,
name text,
secret_notes text MASKED WITH mask_null()
);
INSERT INTO users (user_id, name, secret_notes)
VALUES (uuid(), 'Alice', 'Internal: High priority customer');
-- Regular user sees:
-- user_id | name | secret_notes
-- -------------------------------------+-------+--------------
-- a1b2c3d4-... | Alice | null
-- User with UNMASK sees:
-- user_id | name | secret_notes
-- -------------------------------------+-------+---------------------------------
-- a1b2c3d4-... | Alice | Internal: High priority customer

Replaces the value with a default value based on the column's data type.

Signature:

mask_default(value)

Default values by type:

Data TypeDefault Value
text, varchar, asciiEmpty string ''
int, bigint, smallint, tinyint0
float, double0.0
decimal0
booleanfalse
uuid, timeuuid00000000-0000-0000-0000-000000000000
timestamp1970-01-01 00:00:00+0000 (epoch)
date1970-01-01 (epoch)
blobEmpty blob
list, set, mapEmpty collection

Example:

CREATE TABLE accounts (
account_id uuid PRIMARY KEY,
holder_name text,
balance decimal MASKED WITH mask_default(),
is_premium boolean MASKED WITH mask_default()
);
INSERT INTO accounts (account_id, holder_name, balance, is_premium)
VALUES (uuid(), 'Bob Smith', 50000.00, true);
-- Regular user sees:
-- account_id | holder_name | balance | is_premium
-- -----------+-------------+---------+-----------
-- ... | Bob Smith | 0 | false
-- User with UNMASK sees actual values

Replaces the value with a specified constant.

Signature:

mask_replace(value, replacement)

Parameters:

ParameterDescription
valueThe column value (automatic)
replacementConstant value to show instead

Example:

CREATE TABLE employees (
emp_id uuid PRIMARY KEY,
name text,
department text,
salary int MASKED WITH mask_replace(0),
ssn text MASKED WITH mask_replace('XXX-XX-XXXX')
);
INSERT INTO employees (emp_id, name, department, salary, ssn)
VALUES (uuid(), 'Carol', 'Engineering', 95000, '123-45-6789');
-- Regular user sees:
-- emp_id | name | department | salary | ssn
-- -------+-------+-------------+--------+-------------
-- ... | Carol | Engineering | 0 | XXX-XX-XXXX

Masks the inner portion of a string, preserving outer characters.

Signature:

mask_inner(value, prefix_length, suffix_length)
mask_inner(value, prefix_length, suffix_length, replacement_char)

Parameters:

ParameterDescriptionDefault
valueThe column value (automatic)-
prefix_lengthCharacters to show at startRequired
suffix_lengthCharacters to show at endRequired
replacement_charCharacter for masking*

Example:

CREATE TABLE customers (
customer_id uuid PRIMARY KEY,
name text,
email text MASKED WITH mask_inner(2, 4),
credit_card text MASKED WITH mask_inner(0, 4, '#')
);
INSERT INTO customers (customer_id, name, email, credit_card)
VALUES (uuid(), 'David', 'david.jones@example.com', '4532-1234-5678-9012');
-- Regular user sees:
-- customer_id | name | email | credit_card
-- ------------+-------+------------------------+--------------------
-- ... | David | da***************m.com | ###############9012
-- Email: 'da' (first 2) + masked + '.com' (last 4)
-- Card: all masked except last 4

Masks the outer portions of a string, preserving inner characters.

Signature:

mask_outer(value, prefix_length, suffix_length)
mask_outer(value, prefix_length, suffix_length, replacement_char)

Parameters:

ParameterDescriptionDefault
valueThe column value (automatic)-
prefix_lengthCharacters to mask at startRequired
suffix_lengthCharacters to mask at endRequired
replacement_charCharacter for masking*

Example:

CREATE TABLE contacts (
contact_id uuid PRIMARY KEY,
name text,
phone text MASKED WITH mask_outer(0, 4),
account_number text MASKED WITH mask_outer(4, 4, 'X')
);
INSERT INTO contacts (contact_id, name, phone, account_number)
VALUES (uuid(), 'Eve', '555-123-4567', 'ACCT-78901234-USD');
-- Regular user sees:
-- contact_id | name | phone | account_number
-- -----------+------+--------------+------------------
-- ... | Eve | 555-123-**** | XXXX-78901234XXXX
-- Phone: last 4 masked
-- Account: first 4 and last 4 masked

Replaces the value with a hash using a specified algorithm.

Signature:

mask_hash(value)
mask_hash(value, algorithm)

Parameters:

ParameterDescriptionDefault
valueThe column value (automatic)-
algorithmHash algorithm nameSHA-256

Supported algorithms:

AlgorithmOutput LengthNotes
MD532 hex charsNot recommended for security
SHA-140 hex charsLegacy, use SHA-256 instead
SHA-25664 hex charsRecommended default
SHA-38496 hex charsHigher security
SHA-512128 hex charsMaximum security

Example:

CREATE TABLE audit_log (
log_id uuid PRIMARY KEY,
timestamp timestamp,
user_id text MASKED WITH mask_hash(),
action text,
ip_address text MASKED WITH mask_hash('SHA-256')
);
INSERT INTO audit_log (log_id, timestamp, user_id, action, ip_address)
VALUES (uuid(), toTimestamp(now()), 'alice', 'LOGIN', '192.168.1.100');
-- Regular user sees:
-- log_id | timestamp | user_id | action | ip_address
-- -------+-----------+----------------------------------+--------+----------------------------------
-- ... | ... | 2bd806c97f0e00af1a1fc3328fa763a9 | LOGIN | a7b9d4f2e1c3... (SHA-256 hash)

Hash Determinism

Hash output is deterministic—the same input always produces the same hash. This enables:

  • Joining masked tables on hashed columns
  • Comparing masked values for equality
  • Tracking unique values without revealing them

However, common values may be vulnerable to dictionary attacks.


Two permissions control DDM access:

Grants ability to see original (unmasked) column values.

-- Grant UNMASK on specific table
GRANT UNMASK ON my_keyspace.users TO admin_role;
-- Grant UNMASK on all tables in keyspace
GRANT UNMASK ON KEYSPACE my_keyspace TO admin_role;
-- Grant UNMASK on all keyspaces
GRANT UNMASK ON ALL KEYSPACES TO superadmin;
ScenarioResult
User has UNMASKSees original values
User lacks UNMASKSees masked values
SuperuserAlways sees original values

Grants ability to query tables with masked columns (seeing masked values).

-- Grant SELECT_MASKED on specific table
GRANT SELECT_MASKED ON my_keyspace.users TO analyst_role;
-- Grant SELECT_MASKED on keyspace
GRANT SELECT_MASKED ON KEYSPACE my_keyspace TO analyst_role;

Permission Hierarchy

  • SELECT alone: Cannot query tables with masked columns (error)
  • SELECT + SELECT_MASKED: Can query, sees masked values
  • SELECT + UNMASK: Can query, sees original values
  • UNMASK implies SELECT_MASKED
Permission Levels for Masked TablesPermission Levels for Masked TablesNo SELECT_MASKEDNo UNMASKSELECT_MASKEDNo UNMASKUNMASK(implies SELECT_MASKED)Error:Unauthorized to selectmasked columnsSees:Masked values(*--1234)Sees:Original values(123-45-1234)Table with Masked ColumnsQueryQueryQuery

Setup example:

-- Create roles
CREATE ROLE app_service WITH PASSWORD = 'secret' AND LOGIN = true;
CREATE ROLE analyst WITH PASSWORD = 'secret' AND LOGIN = true;
CREATE ROLE admin WITH PASSWORD = 'secret' AND LOGIN = true;
-- app_service: Can query, sees masked data
GRANT SELECT ON KEYSPACE production TO app_service;
GRANT SELECT_MASKED ON KEYSPACE production TO app_service;
-- analyst: Same as app_service (explicit permissions)
GRANT SELECT ON KEYSPACE production TO analyst;
GRANT SELECT_MASKED ON KEYSPACE production TO analyst;
-- admin: Can see original data
GRANT SELECT ON KEYSPACE production TO admin;
GRANT UNMASK ON KEYSPACE production TO admin;

Apply masking when creating a table:

CREATE TABLE customers (
customer_id uuid PRIMARY KEY,
email text MASKED WITH mask_inner(2, 4),
phone text MASKED WITH mask_outer(0, 4),
ssn text MASKED WITH mask_replace('XXX-XX-XXXX'),
credit_score int MASKED WITH mask_default(),
notes text MASKED WITH mask_null()
);

Add masking to existing columns:

-- Add masking to existing column
ALTER TABLE customers ALTER email MASKED WITH mask_inner(2, 4);
-- Add masking to another column
ALTER TABLE customers ALTER ssn MASKED WITH mask_replace('REDACTED');

Remove masking from columns:

-- Remove masking from column
ALTER TABLE customers ALTER email DROP MASKED;

Check which columns have masking:

DESCRIBE TABLE customers;

Output includes masking function definitions:

CREATE TABLE my_keyspace.customers (
customer_id uuid PRIMARY KEY,
email text MASKED WITH mask_inner(2, 4),
phone text MASKED WITH mask_outer(0, 4),
ssn text MASKED WITH mask_replace('XXX-XX-XXXX'),
...
)

User-defined functions (UDFs) can serve as custom masking functions for specialized requirements.

  1. UDFs must be enabled in cassandra.yaml
  2. Function must accept the column value as the first parameter
  3. Function must return the same type as the column (or compatible type)
  4. Function should be deterministic for consistent results

Mask the local part of an email while preserving the domain:

-- Create custom masking function
CREATE FUNCTION mask_email_local(email text)
RETURNS NULL ON NULL INPUT
RETURNS text
LANGUAGE java
AS $$
int atIndex = email.indexOf('@');
if (atIndex <= 0) return "****";
return "****" + email.substring(atIndex);
$$;
-- Apply to column
CREATE TABLE contacts (
id uuid PRIMARY KEY,
name text,
email text MASKED WITH mask_email_local()
);
INSERT INTO contacts (id, name, email) VALUES (uuid(), 'Alice', 'alice.smith@company.com');
-- Regular user sees:
-- id | name | email
-- ----+-------+------------------
-- ... | Alice | ****@company.com

Show area code, mask the rest:

CREATE FUNCTION mask_phone_partial(phone text)
RETURNS NULL ON NULL INPUT
RETURNS text
LANGUAGE java
AS $$
if (phone == null || phone.length() < 7) return "***-***-****";
// Keep first 3 chars (area code), mask rest
String cleaned = phone.replaceAll("[^0-9]", "");
if (cleaned.length() < 10) return "***-***-****";
return cleaned.substring(0, 3) + "-***-****";
$$;
-- Apply to column
ALTER TABLE customers ALTER phone MASKED WITH mask_phone_partial();

Show only the year of a date:

CREATE FUNCTION mask_date_year(d date)
RETURNS NULL ON NULL INPUT
RETURNS date
LANGUAGE java
AS $$
java.time.LocalDate localDate = java.time.LocalDate.ofEpochDay(d.getDaysSinceEpoch());
return com.datastax.driver.core.LocalDate.fromYearMonthDay(
localDate.getYear(), 1, 1);
$$;
-- Show only birth year
ALTER TABLE users ALTER birth_date MASKED WITH mask_date_year();

Protect patient information while allowing necessary access:

CREATE TABLE patients (
patient_id uuid PRIMARY KEY,
name text,
date_of_birth date MASKED WITH mask_default(),
ssn text MASKED WITH mask_inner(0, 4),
diagnosis_codes list<text>,
insurance_id text MASKED WITH mask_inner(2, 2),
attending_physician text,
room_number text
);
-- Medical staff: Full access
CREATE ROLE medical_staff;
GRANT SELECT ON healthcare.patients TO medical_staff;
GRANT UNMASK ON healthcare.patients TO medical_staff;
-- Billing staff: Limited access (sees masked PHI)
CREATE ROLE billing_staff;
GRANT SELECT ON healthcare.patients TO billing_staff;
GRANT SELECT_MASKED ON healthcare.patients TO billing_staff;
-- Research staff: Anonymized access
CREATE ROLE research_staff;
GRANT SELECT ON healthcare.patients TO research_staff;
GRANT SELECT_MASKED ON healthcare.patients TO research_staff;
-- Only sees: patient_id, masked dates, diagnosis_codes, attending_physician

Protect cardholder data:

CREATE TABLE transactions (
transaction_id uuid PRIMARY KEY,
timestamp timestamp,
card_number text MASKED WITH mask_inner(0, 4),
cardholder_name text MASKED WITH mask_replace('CARDHOLDER'),
amount decimal,
merchant_id text,
authorization_code text MASKED WITH mask_hash()
);
-- Payment processors: Full access
CREATE ROLE payment_processor;
GRANT SELECT ON financial.transactions TO payment_processor;
GRANT UNMASK ON financial.transactions TO payment_processor;
-- Fraud analysts: Partial access (last 4 of card visible)
CREATE ROLE fraud_analyst;
GRANT SELECT ON financial.transactions TO fraud_analyst;
GRANT SELECT_MASKED ON financial.transactions TO fraud_analyst;
-- Reporting: Aggregates only, no card data
CREATE ROLE reporting;
GRANT SELECT ON financial.transactions TO reporting;
GRANT SELECT_MASKED ON financial.transactions TO reporting;

Protect customer PII:

CREATE TABLE customers (
customer_id uuid PRIMARY KEY,
email text MASKED WITH mask_inner(2, 4),
phone text MASKED WITH mask_outer(0, 4),
shipping_address text MASKED WITH mask_replace('[ADDRESS HIDDEN]'),
payment_method_token text MASKED WITH mask_null(),
preferences map<text, text>,
created_at timestamp
);
CREATE TABLE orders (
order_id uuid,
customer_id uuid,
order_date timestamp,
items list<frozen<order_item>>,
shipping_address text MASKED WITH mask_replace('[ADDRESS]'),
total decimal,
PRIMARY KEY (customer_id, order_id)
);
-- Customer service: Can see contact info
CREATE ROLE customer_service;
GRANT SELECT ON ecommerce.customers TO customer_service;
GRANT SELECT ON ecommerce.orders TO customer_service;
GRANT SELECT_MASKED ON KEYSPACE ecommerce TO customer_service;
-- Sees partial email/phone for verification
-- Fulfillment: Needs shipping addresses
CREATE ROLE fulfillment;
GRANT SELECT ON ecommerce.orders TO fulfillment;
GRANT UNMASK ON ecommerce.orders TO fulfillment;
-- Sees full shipping addresses
-- Analytics: No PII access
CREATE ROLE analytics;
GRANT SELECT ON ecommerce.orders TO analytics;
GRANT SELECT_MASKED ON ecommerce.orders TO analytics;
-- Sees order data with masked addresses

Isolate tenant data with masking:

CREATE TABLE tenant_users (
tenant_id uuid,
user_id uuid,
email text MASKED WITH mask_inner(1, 4),
api_key text MASKED WITH mask_hash(),
permissions set<text>,
PRIMARY KEY (tenant_id, user_id)
);
-- Tenant admins: Full access to own tenant
-- (Enforce tenant_id filtering at application layer)
CREATE ROLE tenant_admin;
GRANT SELECT ON saas.tenant_users TO tenant_admin;
GRANT UNMASK ON saas.tenant_users TO tenant_admin;
-- Platform support: Cross-tenant but masked
CREATE ROLE platform_support;
GRANT SELECT ON saas.tenant_users TO platform_support;
GRANT SELECT_MASKED ON saas.tenant_users TO platform_support;

AspectImpactNotes
Read latencyMinimalMasking functions are lightweight
Write latencyNoneMasking only applies at read time
MemoryMinimalFunctions executed per-row
CPULowSimple string operations
StorageNoneOriginal data stored unchanged

Aggregations operate on masked values for users without UNMASK:

-- User without UNMASK
SELECT COUNT(*) FROM customers; -- Works
SELECT AVG(balance) FROM accounts; -- Returns AVG of masked values (likely 0)
SELECT customer_id, balance FROM accounts; -- Shows masked balance

Aggregate Accuracy

Numeric aggregations (SUM, AVG, MIN, MAX) produce incorrect results when run by users seeing masked values. Grant UNMASK to roles requiring accurate aggregates on sensitive columns.

Indexes can be created on masked columns:

CREATE INDEX ON customers (email);
User TypeIndex Behavior
With UNMASKIndex queries return matching rows
Without UNMASKIndex queries use masked values (unlikely to match)
  • Backups contain original (unmasked) data
  • Restore operations preserve masking definitions
  • Backup access should be restricted to authorized personnel

DESCRIBE TABLE output includes masking definitions:

DESCRIBE TABLE customers;
-- Shows: email text MASKED WITH mask_inner(2, 4),

LimitationDescription
Column typesSome masking functions only work with specific types
UDF securityCustom functions run with database permissions
Materialized viewsMasking applies to base table, not MV
Secondary indexesQueries by masked values usually return no results
Partition keysCannot mask partition key columns
Clustering keysCannot mask clustering key columns
CREATE TABLE example (
pk uuid, -- Cannot mask (partition key)
ck timestamp, -- Cannot mask (clustering key)
regular_col text, -- Can mask
static_col text STATIC, -- Can mask
PRIMARY KEY (pk, ck)
);

  1. Enable DDM in cassandra.yaml across all nodes
  2. Rolling restart the cluster
  3. Add masking to columns using ALTER TABLE
  4. Create/update roles with appropriate permissions
  5. Test with different user roles
-- Step 3: Add masking
ALTER TABLE customers ALTER ssn MASKED WITH mask_inner(0, 4);
ALTER TABLE customers ALTER email MASKED WITH mask_inner(2, 4);
-- Step 4: Update permissions
GRANT SELECT_MASKED ON my_keyspace.customers TO app_role;
GRANT UNMASK ON my_keyspace.customers TO admin_role;
-- Connect as regular user
-- Should see masked values
SELECT * FROM customers LIMIT 5;
-- Connect as admin user
-- Should see original values
SELECT * FROM customers LIMIT 5;
-- Verify permissions
LIST ALL PERMISSIONS OF app_role;
LIST ALL PERMISSIONS OF admin_role;

FunctionPurposeExample Output
mask_null()Replace with nullnull
mask_default()Replace with type default0, '', false
mask_replace(val)Replace with constant'REDACTED'
mask_inner(p, s)Mask inner charactershe***rld
mask_outer(p, s)Mask outer characters**llo wo**
mask_hash()Replace with hasha7b9d4f2...
PermissionGrants
SELECTBasic query access (errors on masked tables without SELECT_MASKED)
SELECT_MASKEDQuery masked tables, see masked values
UNMASKQuery masked tables, see original values
cassandra.yaml
dynamic_data_masking_enabled: true # Enable DDM
user_defined_functions_enabled: true # Enable custom masking UDFs