{"id":675,"date":"2026-09-12T13:04:09","date_gmt":"2026-09-12T13:04:09","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/postgresql-data-masking-anonymizer-3-2-the-staging-dump-i-stopped-trusting\/"},"modified":"2026-09-12T13:04:09","modified_gmt":"2026-09-12T13:04:09","slug":"postgresql-data-masking-anonymizer-3-2-the-staging-dump-i-stopped-trusting","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/postgresql-data-masking-anonymizer-3-2-the-staging-dump-i-stopped-trusting\/","title":{"rendered":"PostgreSQL Data Masking: The Staging Dump I Stopped Trusting"},"content":{"rendered":"<p>Confession: for about two years, my &ldquo;staging database&rdquo; for one client project was a nightly pg_dump of production with the passwords column nulled out. That was the whole anonymisation strategy. Real names, real emails, real phone numbers, sitting on a staging box that three contractors and one intern had credentials for. I knew it was bad. I kept telling myself I&rsquo;d fix it after the next release.<\/p>\n<p>What finally made me fix it wasn&rsquo;t a compliance audit. It was a support ticket. A customer got a &ldquo;your order has shipped&rdquo; email from staging, because someone ran a queue worker against the staging copy and the email column was real. That&rsquo;s the day I installed PostgreSQL Anonymizer, and it&rsquo;s been in every Postgres project I&rsquo;ve touched since.<\/p>\n<p>Version 3.2 came out on September 4th, and it changes two things I care about. The pseudonymisation functions got a rewrite (40x faster, per Dalibo), and there&rsquo;s a new security barrier that stops superusers from running masking at all. The second one broke my script on the first run, which is why I&rsquo;m writing this post instead of the one I had planned.<\/p>\n<h2 id=\"what-postgresql-data-masking-actually-means-here\">What postgresql data masking actually means here<\/h2>\n<p>The extension is called <code>anon<\/code> once it&rsquo;s installed, and the mental model is simple. You attach a masking rule to a column using a <code>SECURITY LABEL<\/code>. Then you pick how that rule gets applied: permanently rewrite the table (static masking), rewrite on the fly for certain roles (dynamic masking), or apply it while producing a dump (anonymous dumps). The <a href=\"https:\/\/www.postgresql.org\/about\/news\/postgresql-anonymizer-32-faster-pseudonymization-3373\/\" rel=\"nofollow noopener\" target=\"_blank\">release announcement<\/a> lists six strategies, but those three cover everything I&rsquo;ve needed.<\/p>\n<p>A rule looks like this:<\/p>\n<pre><code class=\"language-sql\">CREATE EXTENSION IF NOT EXISTS anon CASCADE;\nSELECT anon.init();\n\nSECURITY LABEL FOR anon ON COLUMN customers.full_name\n  IS 'MASKED WITH FUNCTION anon.fake_last_name()';\n\nSECURITY LABEL FOR anon ON COLUMN customers.email\n  IS 'MASKED WITH FUNCTION anon.random_email()';\n\nSECURITY LABEL FOR anon ON COLUMN customers.phone\n  IS 'MASKED WITH FUNCTION anon.partial(phone, 0, ''***-***-'', 4)';\n<\/code><\/pre>\n<p>The <code>anon.init()<\/code> call loads a small fake dataset (about 1000 values per category, English only by default) that the faking functions draw from. If you skip it, <code>fake_last_name()<\/code> errors out and the message doesn&rsquo;t tell you why. I lost twenty minutes to that in 2024 and I still forget it on fresh installs.<\/p>\n<p>The reason I like the <code>SECURITY LABEL<\/code> approach over a hand-written UPDATE script is that the rules live in the database schema. They travel with the schema dump. When a colleague adds a <code>date_of_birth<\/code> column, the review question is &ldquo;where&rsquo;s the masking label?&rdquo; and it&rsquo;s visible in the same migration.<\/p>\n<h2 id=\"where-my-old-script-went-wrong\">Where my old script went wrong<\/h2>\n<p>My previous approach, the one I&rsquo;m slightly embarrassed by, was a bash file that ran <code>pg_dump<\/code>, restored it into staging, then ran a <code>psql -f mask.sql<\/code> with a bunch of UPDATE statements. Three problems with that, and I hit all of them.<\/p>\n<p>First, the real data touched the staging disk before masking ran. If the mask step failed halfway (it did, twice, both times on a foreign key I hadn&rsquo;t accounted for), staging sat there with production data until someone noticed.<\/p>\n<p>Second, <code>UPDATE customers SET email = 'user' || id || '@example.com'<\/code> destroys the relationships you want to test against. Every customer had a distinct email, fine, but customer 42&rsquo;s email in the <code>orders_archive<\/code> table no longer matched customer 42 in <code>customers<\/code>. Join-heavy reports fell over on staging and nowhere else.<\/p>\n<p>Third, it was slow. A 9 million row <code>events<\/code> table with a masked <code>ip_address<\/code> column took 40 minutes under my UPDATE approach because the fake-value function was called per row with no caching.<\/p>\n<p>The extension&rsquo;s anonymous dump mode fixes the first problem outright. You create a role that is flagged as masked, and plain <code>pg_dump<\/code> run as that role sees only masked values, so the rules are applied while the dump is being written. Production data never lands on the target machine. (If you remember <code>pg_dump_anon.sh<\/code> from older versions, it&rsquo;s deprecated now; the masked-role approach replaced it.) I now do this from a dedicated role on the production replica, which brings me to the 3.2 change that bit me.<\/p>\n<h2 id=\"the-superuser-barrier-that-broke-my-cron-job\">The superuser barrier that broke my cron job<\/h2>\n<p>Before 3.2, I ran the dump as the <code>postgres<\/code> superuser because that&rsquo;s what my Ansible role had always done and nobody had questioned it. After upgrading, the job failed with a permissions error even though the role could obviously do anything.<\/p>\n<p>That&rsquo;s intentional. 3.2 introduces a barrier where the extension refuses to run any masking function on behalf of a superuser. Dalibo&rsquo;s stated reason is least privilege, and it sits next to three CVEs fixed in the same release (CVE-2026-19633, CVE-2026-19634, CVE-2026-83534), all of which are privilege escalation paths. Two of them let a user gain superuser under the right conditions, and the announcement says the risk is &ldquo;very high&rdquo; on PostgreSQL 14 and on instances upgraded from 14 or earlier. If you&rsquo;re on that version, upgrade the extension first and read this post second.<\/p>\n<p>The fix on my side was a dedicated masked role, which is what the <a href=\"https:\/\/postgresql-anonymizer.readthedocs.io\/en\/latest\/anonymous_dumps\/\" rel=\"nofollow noopener\" target=\"_blank\">anonymous dumps docs<\/a> recommend anyway:<\/p>\n<pre><code class=\"language-sql\">CREATE ROLE dump_anon LOGIN PASSWORD '...';\nALTER ROLE dump_anon SET anon.transparent_dynamic_masking = true;\nSECURITY LABEL FOR anon ON ROLE dump_anon IS 'MASKED';\nGRANT pg_read_all_data TO dump_anon;\n<\/code><\/pre>\n<p>And then the dump job becomes ordinary <code>pg_dump<\/code>, run as that role:<\/p>\n<pre><code class=\"language-bash\">pg_dump app --user dump_anon \\\n  --no-security-labels \\\n  --exclude-extension=anon \\\n  --format=custom \\\n  --file=app_anon.dump\n<\/code><\/pre>\n<p>The <code>--no-security-labels<\/code> flag matters more than it looks. It strips the masking rules out of the dump, so whoever restores staging can&rsquo;t read your masking policy and reason backwards from it. <code>--exclude-extension<\/code> needs pg_dump 17 or later; on older versions the docs suggest <code>--extension plpgsql<\/code> instead. There&rsquo;s an escape hatch, <code>anon.nosuperuser = false<\/code>, that restores the old behaviour. I&rsquo;d rather not. If a piece of software tells me my cron job has been running with more privilege than it needs for two years, the right response is to fix the cron job, not disable the warning.<\/p>\n<h2 id=\"seeded-functions-same-input-same-fake-40x-faster\">Seeded functions: same input, same fake, 40x faster<\/h2>\n<p>The headline feature in 3.2 is the replacement of <code>anon.pseudo_*<\/code> with <code>anon.seeded_*<\/code>. Pseudonymisation here means the fake value is deterministic. Feed the same real email in, get the same fake email out, every time. That&rsquo;s what makes joins survive masking: customer 42&rsquo;s fake email in <code>customers<\/code> matches customer 42&rsquo;s fake email in <code>orders_archive<\/code>, because both were derived from the same seed.<\/p>\n<p>The old way:<\/p>\n<pre><code class=\"language-sql\">SECURITY LABEL FOR anon ON COLUMN customers.email\n  IS 'MASKED WITH FUNCTION anon.pseudo_email(email)';\n<\/code><\/pre>\n<p>The new way, with a locale and a salt:<\/p>\n<pre><code class=\"language-sql\">SECURITY LABEL FOR anon ON COLUMN customers.email\n  IS 'MASKED WITH FUNCTION anon.seeded_email(email, ''en_US'', ''staging-2026'')';\n\nSECURITY LABEL FOR anon ON COLUMN customers.city\n  IS 'MASKED WITH FUNCTION anon.seeded_city(city, ''fr_FR'', ''staging-2026'')';\n<\/code><\/pre>\n<p>The <code>pseudo_*<\/code> functions still exist but are deprecated, so migrate now while it&rsquo;s a find-and-replace and not a 2am outage. The <a href=\"https:\/\/postgresql-anonymizer.readthedocs.io\/en\/latest\/masking_functions\/\" rel=\"nofollow noopener\" target=\"_blank\">masking functions docs<\/a> list all ten seeded functions and the signature is consistent: seed, locale, salt.<\/p>\n<p>Two things to know before you trust this. The salt matters. Without one, anybody who can guess the fake dataset and the hashing method can, in principle, reverse a pseudonym by brute force over likely inputs. Dalibo says this plainly in the docs section titled &ldquo;Pseudonymization IS NOT Anonymization&rdquo;, and I&rsquo;d rather quote their caution than pretend it&rsquo;s a solved problem. A salt stored outside the database (in your secrets manager, not in the masking rule text if that rule is in version control) closes most of that gap for a staging use case.<\/p>\n<p>The second is collisions. The default fake dataset is 1000 values per category. If you have 50,000 distinct last names and seed them into a pool of 1000, you get collisions by construction. That&rsquo;s fine for <code>last_name<\/code> (two customers sharing a surname is realistic). It is not fine for <code>email<\/code> if your schema has a unique constraint on it, which mine did. The seeded email function builds from name components so the effective pool is larger, but I still hit two unique-violation errors on a 400k row table. My fix was to append the pseudonymised id: <code>anon.seeded_email(email, 'en_US', 'salt') || '.' || id<\/code>. Ugly, and I&rsquo;m open to a better idea.<\/p>\n<p>On speed: I don&rsquo;t have a rigorous benchmark to give you, and I&rsquo;m suspicious of &ldquo;40x&rdquo; claims in general. What I can say is that the 9 million row events table that used to take 40 minutes under my UPDATE script now dumps in under 4 with <code>seeded_*<\/code> rules applied through the masked role. Some of that is the dump path itself and not the functions. I&rsquo;m still not sure how much.<\/p>\n<h2 id=\"where-this-sits-in-a-small-teams-setup\">Where this sits in a small team&rsquo;s setup<\/h2>\n<p>If you&rsquo;re one developer or a team of three with a Hetzner box and a Postgres container, here&rsquo;s the shape of what I run now. I wrote up the box itself in <a href=\"https:\/\/abrarqasim.com\/blog\/hetzner-vs-digitalocean-what-this-blog-actually-runs-on\/\" rel=\"noopener\">what this blog actually runs on<\/a> if you want the infrastructure side.<\/p>\n<p>A systemd timer on the production host runs <code>pg_dump<\/code> nightly as the <code>dump_anon<\/code> role, writes to a local file, and rsyncs it to the staging host. A second timer on staging drops and recreates the database from that file. The masking rules live in a migration file in the app repo, applied by the same migration runner as everything else, so a new PII column without a label fails code review rather than leaking.<\/p>\n<p>Dynamic masking for live analyst access is the mode I haven&rsquo;t adopted and probably won&rsquo;t. Technically the dump role above uses the same machinery (results are rewritten on the fly for roles flagged as masked), but it runs once a night. Handing an analyst a masked role on production means every query they run pays the masking cost, and I&rsquo;d rather they hit the nightly anonymised copy. If you&rsquo;re using pg_stat_statements to chase slow queries the way I described in <a href=\"https:\/\/abrarqasim.com\/blog\/pg-stat-statements-four-queries-before-adding-an-index\/\" rel=\"noopener\">four queries before adding an index<\/a>, dynamic masking will show up in your top statements and confuse you.<\/p>\n<p>One more honest limitation. The extension masks columns you label. It does nothing about PII that ends up in a JSONB blob, a free-text <code>notes<\/code> column, or a log table with request bodies. My <code>support_tickets.body<\/code> column had customer phone numbers in it for months after I thought staging was clean. I now mask that column with a plain <code>MASKED WITH VALUE 'redacted'<\/code> because there&rsquo;s no realistic fake for free text, and the support features on staging get tested with seed data instead.<\/p>\n<h2 id=\"what-to-do-this-week\">What to do this week<\/h2>\n<p>Run one query against your staging database:<\/p>\n<pre><code class=\"language-sql\">SELECT email, phone FROM customers ORDER BY random() LIMIT 5;\n<\/code><\/pre>\n<p>If you recognise any of those people, that&rsquo;s the whole argument. Install the extension on the production replica (Debian and RPM packages, a Docker image, and an Ansible role all exist), write labels for the five most obvious columns, create a non-superuser dump role, and switch your staging refresh to a <code>pg_dump<\/code> run as that role. It took me an afternoon, most of which was the unique-constraint problem above.<\/p>\n<p>If your Postgres is on 14 or was upgraded from 14, upgrade the extension to 3.2 today regardless of anything else in this post. The CVEs are the part of the announcement that doesn&rsquo;t wait for a convenient sprint.<\/p>\n<p>I do this kind of database and infrastructure cleanup for clients as part of my <a href=\"https:\/\/abrarqasim.com\" rel=\"noopener\">freelance work<\/a>, usually as the unglamorous first week of a bigger project. It&rsquo;s never the exciting part. It&rsquo;s the part that stops the shipped-order email from going to a real customer.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>PostgreSQL Anonymizer 3.2 adds seeded pseudonymisation and blocks superuser masking. How I replaced a risky staging dump with a masked role, with the SQL.<\/p>\n","protected":false},"author":2,"featured_media":674,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"PostgreSQL Anonymizer 3.2 adds seeded pseudonymisation and blocks superuser masking. How I replaced a risky staging dump with a masked role, with the SQL.","rank_math_focus_keyword":"postgresql data masking","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[302,45],"tags":[745,744,80,238,154,746],"class_list":["post-675","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-devops","category-programming","tag-anonymization","tag-data-masking","tag-devops","tag-postgresql","tag-security","tag-staging"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/675","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=675"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/675\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/674"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=675"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=675"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=675"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}