The short answer
Customer PII in a GCC e-commerce or fintech app is protected by three separate things: authentication that proves who is calling, row-level security policies in Postgres that constrain what each caller can reach, and field-level encryption for the columns that would end a business if they leaked — and enabling row-level security without testing the policies protects nothing.
  • The number one API risk — Broken Object Level Authorization sits at API1 in the OWASP API Security Top 10 (2023 edition), and it is exactly what a permissive row-level policy creates.
  • Never store the CVV — PCI DSS forbids retaining sensitive authentication data after authorisation, and version 4.0's future-dated requirements became mandatory on 31 March 2025.
  • The service key bypasses everything — Supabase's service_role key ignores row-level security entirely by design, so one copy of it in browser-reachable code makes every policy irrelevant.
  • Fines are enforceable now — Saudi Arabia's Personal Data Protection Law allows penalties up to SAR 5 million, and the UAE's Federal Decree-Law No. 45 of 2021 sets its own obligations.

The team had done the right thing. They had enabled row-level security on all fourteen tables, which is precisely what the documentation tells you to do, and they had the screenshot to prove it. Six weeks after launch, any signed-in customer of that Dubai storefront could read every other customer's order — name, phone number, delivery address, and the last four digits of the card.

The cause was one line. Every policy had been written as using (true), which reads as "allow this row" for every row in the table. The generator had produced a syntactically perfect policy that granted universal access, the security toggle showed green in the dashboard, and nothing anywhere said the word "warning". I run NICGULF, the Gulf arm of IndiaNIC, and what that afternoon settled for me is this: a control that is switched on is not the same as a control that is doing something, and almost every breach I have looked at since sits in that gap.

Why GCC retail and fintech prototypes break in the same place

The pattern has a cause, and the cause is not carelessness.

An AI code generator builds a working application by making the application work. Access control does the opposite of that — it stops things from working. So when a founder says "build me a marketplace where sellers manage their listings," the model produces exactly that, and the fastest way to produce it is a permissive default that never blocks anybody during development.

Retail and fintech prototypes then hit the region's specific weight of data. A GCC e-commerce order carries a phone number that is also a WhatsApp identity, an address in a country where addressing is often descriptive rather than numeric, and increasingly an Emirates ID or national ID number captured for delivery verification. A fintech onboarding flow carries all of that plus an IBAN, a salary certificate and a photo of a passport page.

That is not the same risk profile as a European retail app, and the assumptions baked into a generated codebase were learned mostly from European and American ones.

A counter with a card payment terminal, a tablet on a stand and a plate of dates, where a worker is handing a paper receipt to a customer
Every one of these transactions leaves a row behind. The question is who can read it.

The three-wall model for customer PII

What nobody tells you is that these three walls fail independently, and teams almost always build the first one properly and then stop.

Wall one: proving who is calling

Authentication answers one question — is this request from the person it claims to be. Use a managed provider rather than a hand-rolled users table, enforce multi-factor authentication for anything financial, and check new passwords against a breach corpus such as Have I Been Pwned's range API before accepting them.

Session handling is where generated code slips. Tokens that never expire, refresh tokens stored in local storage where any script on the page can read them, and no revocation path when a customer reports a stolen phone. The OAuth specifications are dull reading and they answer all three.

Wall two: the boundary inside the database

Authorisation answers a harder question — given that this is genuinely Fatima, which rows may she see. That question has exactly one safe place to live, and it is the database.

Row-level security is a PostgreSQL feature that attaches a policy to a table so every query is filtered by a rule the application cannot skip. Enable it, and a table with no policy denies everything. Write a policy tied to the caller's identity — comparing a user_id column against auth.uid() in Supabase, or against a session variable in plain PostgreSQL — and the boundary holds even when a new developer forgets a WHERE clause eight months from now.

Wall three: ciphertext for the fields that would end you

The third wall assumes the first two failed. Encrypt the specific columns whose disclosure would be unrecoverable — national ID, IBAN, passport number, date of birth — with keys held outside the database, so that a dumped table is a file of noise rather than a headline.

Hash what you never need to read back. Encrypt what you do. And store nothing you cannot justify keeping, which is the cheapest control on this entire list and the one teams reach for last.

Search your entire codebase for the service key today. Supabase's service_role key bypasses every row-level security policy by design, and generated frontends put it in the same environment file as the public key with alarming regularity. One copy of it in browser-reachable code makes every policy in your database decorative.

How do you audit Supabase row-level security policies?

Audit Supabase row-level security by testing it as a real user rather than reading the dashboard. Sign in as customer A, request customer B's record directly through the API, and confirm you get nothing back. Then repeat for every table, every view, and every storage bucket. A policy is only proven by a request that was refused.

Five checks catch nearly everything, and they take an afternoon.

  1. Find tables with row-level security disabled. Query pg_tables joined against pg_class.relrowsecurity and list every table where it is false. Anything exposed through the auto-generated API with the flag off is world-readable to any holder of the public key.
  2. Find policies that permit everything. Read pg_policies and look at the qual column. Any policy whose expression is true, or which compares nothing to the caller's identity, is a green light wearing a lock icon.
  3. Check the views. A view created with security definer runs with its owner's privileges and quietly steps around the row-level security on its underlying tables. This is the single most common way a correctly-secured table leaks anyway.
  4. Check storage buckets. Uploaded passport scans and salary certificates live in object storage, not tables, and buckets have their own policies that people forget exist.
  5. Test with two real accounts. Not a unit test with a mocked client — two genuine sessions, and a request from one for the other's data. If it returns 200, you have your finding.
What you findWhat it means in practiceSeverity
RLS disabled on a table with PIIAnyone with the public key reads the whole tableCritical
Policy written as using (true)Any signed-in user reads every other user's rowsCritical
Service key in frontend environment fileEvery policy in the database is bypassedCritical
security definer view over a protected tableThe view returns rows the policy would have blockedHigh
Public storage bucket holding ID documentsPassport scans reachable by URL aloneCritical
Card CVV stored in an orders tableDirect PCI DSS violation, regardless of encryptionCritical

Broken Object Level Authorization is ranked API1 in the OWASP API Security Top 10 for a reason. It is the most common serious API flaw in the world, it requires no exploit tooling to find, and a permissive row-level policy is its textbook form.

A security toggle showing green tells you a feature is enabled. It tells you nothing at all about whether that feature is refusing anybody.

What counts as PII in the GCC, and what must you never store?

Personal data in the GCC includes anything that identifies a person directly or in combination — name, mobile number, email, address, Emirates ID or national ID number, IBAN, passport details, date of birth, location history and device identifiers. Card verification values, full magnetic-stripe data and PIN blocks must never be retained after a transaction is authorised, under any circumstances.

That last sentence is not a preference. The PCI Security Standards Council prohibits storing sensitive authentication data after authorisation, and encrypting it does not make it permissible. I have found a CVV column in a prototype more than once, always added because a developer thought it might be needed for refunds. It is never needed for refunds.

Regulatory weight sits on top. Licensed payment activity in the Emirates falls under rules from the Central Bank of the UAE, financial institutions in the Kingdom under the framework published by the Saudi Central Bank, and personal data across Saudi Arabia under the law administered by SDAIA.

Here is where I will disagree with common practice, and I will accept the cost of being wrong. Most GCC retail teams collect a national ID number at checkout because a courier once asked for it. That is a bad trade. You have taken on the most sensitive identifier in the country, permanently, to solve a delivery problem that a one-time verification code solves better — and if you cannot delete it after delivery, you should not be collecting it at all.

A seven-day plan you can start on Sunday morning

None of this needs budget approval to begin. Order matters, because each step makes the next one cheaper.

  1. Day one: hunt the service key. Search every repository, environment file, build configuration and deployed bundle. Rotate it if it appears anywhere the browser can reach, and treat it as compromised rather than debating whether it was.
  2. Day two: list every table that holds personal data, and mark which of them have row-level security enabled. Expect surprises in the tables nobody talks about — the ones holding webhook payloads and email logs.
  3. Day three: read every policy expression. Anything that does not reference the caller's identity gets rewritten. This is dull and it is the highest-value day of the seven.
  4. Day four: test with two accounts. Sign in as one customer, request another's record, and record what came back. Do it for orders, addresses, payment methods and documents.
  5. Day five: check storage and views. Buckets holding identity documents should be private with signed URLs, and every security definer view needs a reason to exist.
  6. Days six and seven: encrypt and delete. Field-level encryption for national ID, IBAN and passport numbers, using guidance from the National Institute of Standards and Technology on key management — and a retention rule that actually removes what you no longer need.

My own most instructive mistake here was on the fintech side, and it was a design decision rather than a bug. We built a customer support console with a "view as customer" feature because the support team asked for it and it genuinely halved their handling time. It worked by impersonating the customer's session, which meant every row-level policy evaluated as that customer and returned their full record — IBAN included — to whichever agent was on shift. Nothing was breached. Nothing was logged either, which was the actual problem: we could not have told you who had read what. We rebuilt it to show masked fields by default with an explicit, logged reveal, and the support team's handling time went up by about fifteen seconds per ticket. That is what the control cost. It was worth it.

So here is the question I would like you to take to your engineering channel this afternoon, and I would rather you tested it than answered from memory: if you sign in as one of your customers and ask your API for a different customer's order, what comes back? Run it before you reply. Then tell your team the result — because the teams who find this early are the ones who never have to explain it to a regulator.

Frequently asked questions

Does enabling row-level security in Supabase make my data safe?

No, on its own. Enabling row-level security only activates the policy engine; safety comes from the policies themselves. A policy written as using (true) permits every row to every signed-in user while the dashboard reports the table as protected. Test every table with two real accounts before trusting the setting.

Which customer fields should be encrypted at the column level?

Encrypt identifiers whose disclosure cannot be undone: national ID and Emirates ID numbers, passport numbers, IBANs, dates of birth, and any health or biometric data. Names, emails and order histories are usually protected adequately by access control alone. Card verification values must not be stored at all, encrypted or otherwise.

Can a GCC e-commerce app store the card CVV for repeat purchases?

No. PCI DSS prohibits retaining sensitive authentication data, including the card verification value, after a transaction has been authorised, and encryption does not create an exception. Repeat purchases are handled by tokenising the card with the payment gateway, which returns a reference the merchant can safely store instead.