Tested on: Oracle Database 19c Enterprise Edition 19.27 | Level: Intermediate
A production support team needs to query live data to troubleshoot incidents. In a bank, that same data includes customers the support team must never see, such as staff accounts or accounts under legal hold, plus sensitive columns like national ID and phone number. In this post I use Oracle Virtual Private Database (VPD) to give support engineers real read-only access while Oracle silently filters restricted rows and masks sensitive columns, with no change to the application.
The problem
Creating a filtered view for every table does not scale, and application teams will not rewrite queries for one support user. Granting plain SELECT has a second risk: a support user can run SELECT ... FOR UPDATE and lock production rows during an incident.
VPD solves the access part inside the database, and the READ privilege solves the locking part. Everything below was run end to end on a 19c lab database, and every output shown is real.
How VPD works
VPD attaches a policy to a table. When a user queries that table, Oracle calls a PL/SQL policy function, takes the string it returns (for example is_restricted = 'N') and appends it to the statement's WHERE clause before execution. The user never sees or controls this predicate.
VPD is part of Oracle Database Enterprise Edition and is managed with the DBMS_RLS package. The lab builds four things:
- A lab schema
VPD_DEMOwith aCUSTOMERStable - A read-only
SUPPORT_ROuser that cannot modify or lock rows - A row-level policy that hides restricted customers from
SUPPORT_ROonly - A column-masking policy that returns NULL for national ID and phone for
SUPPORT_RO
Step 1: Create the lab schema and data
Connect as SYSDBA. The block below creates the schema in the database's default tablespace, so it works on any 19c install.
DECLARE
l_ts VARCHAR2(128);
BEGIN
SELECT property_value INTO l_ts
FROM database_properties
WHERE property_name = 'DEFAULT_PERMANENT_TABLESPACE';
EXECUTE IMMEDIATE 'CREATE USER vpd_demo IDENTIFIED BY Welcome_2026 '
|| 'DEFAULT TABLESPACE ' || l_ts
|| ' QUOTA UNLIMITED ON ' || l_ts;
END;
/
GRANT CREATE SESSION, CREATE TABLE, CREATE PROCEDURE TO vpd_demo;
GRANT EXECUTE ON sys.dbms_rls TO vpd_demo;
CREATE TABLE vpd_demo.customers (
customer_id NUMBER PRIMARY KEY,
full_name VARCHAR2(100) NOT NULL,
branch_code VARCHAR2(10) NOT NULL,
national_id VARCHAR2(20),
phone VARCHAR2(20),
is_restricted CHAR(1) DEFAULT 'N' NOT NULL
CHECK (is_restricted IN ('Y','N'))
);
INSERT INTO vpd_demo.customers VALUES (1, 'Arun Kumar', 'CHN01', 'ID-1001', '+91-90000-00001', 'N');
INSERT INTO vpd_demo.customers VALUES (2, 'Meena Raj', 'CHN01', 'ID-1002', '+91-90000-00002', 'N');
INSERT INTO vpd_demo.customers VALUES (3, 'Staff Account', 'CHN02', 'ID-1003', '+91-90000-00003', 'Y');
INSERT INTO vpd_demo.customers VALUES (4, 'Priya Shankar', 'BLR01', 'ID-1004', '+91-90000-00004', 'N');
INSERT INTO vpd_demo.customers VALUES (5, 'Legal Hold Ltd', 'BLR01', 'ID-1005', '+91-90000-00005', 'Y');
INSERT INTO vpd_demo.customers VALUES (6, 'Vijay Anand', 'MUM01', 'ID-1006', '+91-90000-00006', 'N');
COMMIT;
Six customers, two of them restricted. All names and IDs are fictional.
Step 2: Create a truly read-only support user
Grant READ, not SELECT. Since Oracle 12c, READ allows queries but blocks SELECT ... FOR UPDATE, so support can never lock application rows.
CREATE USER support_ro IDENTIFIED BY Welcome_2026;
GRANT CREATE SESSION TO support_ro;
GRANT READ ON vpd_demo.customers TO support_ro;
EXEMPT ACCESS POLICY, the DBA role or any ANY system privilege to this user. Any of these can bypass or undermine the policies below.Step 3: Hide restricted rows with a row-level policy
Connect as VPD_DEMO. The policy function returns a predicate only for SUPPORT_RO. Returning NULL means no filter, so the application and every other user are unaffected.
CREATE OR REPLACE FUNCTION support_rows_pred (
p_schema IN VARCHAR2,
p_object IN VARCHAR2
) RETURN VARCHAR2
AS
BEGIN
IF SYS_CONTEXT('USERENV', 'SESSION_USER') = 'SUPPORT_RO' THEN
RETURN 'is_restricted = ''N''';
END IF;
RETURN NULL;
END;
/
BEGIN
DBMS_RLS.ADD_POLICY(
object_schema => 'VPD_DEMO',
object_name => 'CUSTOMERS',
policy_name => 'SUPPORT_RO_ROWS',
function_schema => 'VPD_DEMO',
policy_function => 'SUPPORT_ROWS_PRED',
statement_types => 'SELECT',
policy_type => DBMS_RLS.DYNAMIC
);
END;
/
DYNAMIC for correctness, then evaluate CONTEXT_SENSITIVE if function calls show up as a cost.Step 4: Mask sensitive columns
Support often needs to see that a customer exists, but not their national ID or phone. A column-masking policy keeps the rows and returns NULL in the listed columns wherever its predicate is false. Returning 1 = 0 for SUPPORT_RO masks those columns on every row.
CREATE OR REPLACE FUNCTION support_mask_pred (
p_schema IN VARCHAR2,
p_object IN VARCHAR2
) RETURN VARCHAR2
AS
BEGIN
IF SYS_CONTEXT('USERENV', 'SESSION_USER') = 'SUPPORT_RO' THEN
RETURN '1 = 0';
END IF;
RETURN NULL;
END;
/
BEGIN
DBMS_RLS.ADD_POLICY(
object_schema => 'VPD_DEMO',
object_name => 'CUSTOMERS',
policy_name => 'SUPPORT_RO_MASK',
function_schema => 'VPD_DEMO',
policy_function => 'SUPPORT_MASK_PRED',
statement_types => 'SELECT',
sec_relevant_cols => 'NATIONAL_ID,PHONE',
sec_relevant_cols_opt => DBMS_RLS.ALL_ROWS,
policy_type => DBMS_RLS.DYNAMIC
);
END;
/
Without sec_relevant_cols_opt => DBMS_RLS.ALL_ROWS, Oracle would remove whole rows whenever a query touches those columns, which confuses support users far more than an empty value.
Step 5: Test and verify
The same query, run by two different users:
SELECT customer_id, full_name, national_id, phone, is_restricted
FROM vpd_demo.customers
ORDER BY customer_id;
As VPD_DEMO (the application owner): all 6 rows, all columns
CUSTOMER_ID FULL_NAME NATIONAL_ID PHONE IS_RESTRICTED
----------- ---------------- ------------ ---------------- -------------
1 Arun Kumar ID-1001 +91-90000-00001 N
2 Meena Raj ID-1002 +91-90000-00002 N
3 Staff Account ID-1003 +91-90000-00003 Y
4 Priya Shankar ID-1004 +91-90000-00004 N
5 Legal Hold Ltd ID-1005 +91-90000-00005 Y
6 Vijay Anand ID-1006 +91-90000-00006 N
6 rows selected.
As SUPPORT_RO: restricted customers gone, sensitive columns empty
CUSTOMER_ID FULL_NAME NATIONAL_ID PHONE IS_RESTRICTED
----------- ---------------- ------------ ---------------- -------------
1 Arun Kumar N
2 Meena Raj N
4 Priya Shankar N
6 Vijay Anand N
4 rows selected.
Next, confirm the support user cannot change or lock data:
UPDATE vpd_demo.customers SET phone = NULL WHERE customer_id = 1;
SELECT * FROM vpd_demo.customers WHERE customer_id = 1 FOR UPDATE;
ERROR at line 1:
ORA-01031: insufficient privileges
ERROR at line 1:
ORA-01031: insufficient privileges
The execution plan, generated as SUPPORT_RO, shows the predicate Oracle injected even though the user never wrote it:
EXPLAIN PLAN FOR
SELECT customer_id, full_name FROM vpd_demo.customers;
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY(format => 'BASIC +PREDICATE'));
---------------------------------------
| Id | Operation | Name |
---------------------------------------
| 0 | SELECT STATEMENT | |
|* 1 | TABLE ACCESS FULL| CUSTOMERS |
---------------------------------------
Predicate Information (identified by operation id):
---------------------------------------------------
1 - filter("IS_RESTRICTED"='N')
Finally, verify from the DBA side. DBA_POLICIES lists the policies, and V$VPD_POLICY shows which predicate was applied to each cursor:
SELECT policy_name, function, sel, enable, policy_type
FROM dba_policies
WHERE object_owner = 'VPD_DEMO' AND object_name = 'CUSTOMERS';
SELECT sql_id, policy, predicate
FROM v$vpd_policy
WHERE object_owner = 'VPD_DEMO' AND object_name = 'CUSTOMERS';
POLICY_NAME FUNCTION SEL ENABLE POLICY_TYP
---------------- -------------------- --- ------ ----------
SUPPORT_RO_ROWS SUPPORT_ROWS_PRED YES YES DYNAMIC
SUPPORT_RO_MASK SUPPORT_MASK_PRED YES YES DYNAMIC
SQL_ID POLICY PREDICATE
------------- ---------------- ------------------------------
13au474p4vjy3 SUPPORT_RO_ROWS
13au474p4vjy3 SUPPORT_RO_MASK
13au474p4vjy3 SUPPORT_RO_ROWS is_restricted = 'N'
13au474p4vjy3 SUPPORT_RO_MASK 1 = 0
Production checklist
- Know who bypasses VPD.
SYSand any user withEXEMPT ACCESS POLICYsee everything. Audit it regularly:SELECT grantee FROM dba_sys_privs WHERE privilege = 'EXEMPT ACCESS POLICY'; - Separate the policy owner. In this lab the table owner holds the policy functions to keep things simple. In production, put them in a dedicated security schema so the application owner cannot change the rules that protect its own data.
- Keep policy functions small and safe. If the function fails, every query on the table fails with errors such as ORA-28110, ORA-28112 or ORA-28113. Never query the protected table inside its own policy function.
- Check plans, not just results. The predicate becomes part of every support query. Index the filter column on large tables if it is selective, and confirm with
DBMS_XPLAN. - Identify the user correctly.
SESSION_USERworks when each support engineer or team has its own account. With a shared account or a connection pool, use an application context set at login instead. - Views are covered. A policy on the base table also applies when the table is read through a view.
- Treat policies as code. Keep functions and
ADD_POLICYcalls in version control and deploy them through normal change control.
Rollback and clean-up
-- Temporarily disable a policy without losing its definition
BEGIN
DBMS_RLS.ENABLE_POLICY('VPD_DEMO', 'CUSTOMERS', 'SUPPORT_RO_ROWS', FALSE);
END;
/
-- Remove both policies
BEGIN
DBMS_RLS.DROP_POLICY('VPD_DEMO', 'CUSTOMERS', 'SUPPORT_RO_ROWS');
DBMS_RLS.DROP_POLICY('VPD_DEMO', 'CUSTOMERS', 'SUPPORT_RO_MASK');
END;
/
-- Remove the lab completely (as SYSDBA)
DROP USER support_ro CASCADE;
DROP USER vpd_demo CASCADE;
Summary
With two short functions and two DBMS_RLS.ADD_POLICY calls, the support team gets real read-only access for troubleshooting, restricted customers stay invisible, sensitive columns stay masked and no one can lock production rows. The application needed no changes, and the rules live in the database where every access path is covered.
In a follow-up post I will extend this with an application context, so access rules come from a mapping table instead of hard-coded user names.
Tamilselvan Perumal is an Oracle ACE Apprentice and Senior DBA with 12+ years in banking databases. Connect on LinkedIn.