You wrote a view that filters each sales rep to their own region, granted the view, revoked the table, and
called it row-level security. It worked — until the reporting team needed the base table, or a data export
ran as a privileged account, or someone wrote an ad-hoc query straight against ORDERS. The moment anyone
touches the table instead of the view, the filter is gone. A view is a detour, and a detour only works if
everyone agrees to take it.
Virtual Private Database (VPD, also called Fine-Grained Access Control, implemented through the
DBMS_RLS package) does it the other way around: it attaches the rule to the table. You write a policy
function that returns a WHERE predicate, and Oracle transparently appends that predicate to every query
against the table, per session — no view, no application change, and nothing to route around. Query the
base table directly and the predicate is still there. This is the row-level counterpart to
Data Redaction: redaction masks a column’s value and
leaves the row; VPD removes the row entirely.
This post is what VPD actually is, how the policy function and the exempt privilege work, where it applies (and where people forget it doesn’t), and — the part that ends the argument — a lab where three users query the same table and each one provably sees only the rows they’re allowed to.
What VPD is — a predicate the database adds for you
A VPD policy has two pieces: a policy function and the DBMS_RLS.ADD_POLICY call that binds it to a
table. The function takes the schema and object name and returns a string — a SQL predicate. When a session
queries the protected table, Oracle calls the function, takes whatever predicate it returns, and rewrites the
query to include it. A SELECT * FROM orders issued by a sales rep silently becomes
SELECT * FROM orders WHERE (region = 'EAST').
The crucial word is transparently. The application doesn’t know, the SQL text doesn’t change, and the
predicate is enforced on the base table — so it applies to SELECT COUNT(*), to SUM(amount), to a join,
to a WHERE id = 42 lookup, to an export, to everything. There is no version of the query that sees the
unfiltered table (except for the exempt sessions below). That’s the whole difference from a view: a view is a
separate object you can choose not to use; a VPD predicate is part of every statement against the table
itself.
Here’s the function — it keys off the session user and returns each rep’s region:
CREATE OR REPLACE FUNCTION vpdown.orders_rls(p_schema IN VARCHAR2, p_object IN VARCHAR2)
RETURN VARCHAR2 IS
BEGIN
RETURN CASE SYS_CONTEXT('USERENV','SESSION_USER')
WHEN 'REP_EAST' THEN 'region = ''EAST'''
WHEN 'REP_WEST' THEN 'region = ''WEST'''
ELSE '1=2' -- deny by default: unknown users see no rows
END;
END;
/
And the policy that binds it to the table, for SELECT:
BEGIN
DBMS_RLS.ADD_POLICY(
object_schema => 'VPDOWN',
object_name => 'ORDERS',
policy_name => 'ORDERS_REGION_POLICY',
function_schema => 'VPDOWN',
policy_function => 'ORDERS_RLS',
statement_types => 'SELECT');
END;
/
That’s it. No grants to juggle, no view to maintain, no trigger. From now on every SELECT against
VPDOWN.ORDERS carries the predicate the function hands back for that session.
Key the predicate off something the user can’t forge
The policy function above uses SYS_CONTEXT('USERENV','SESSION_USER') — the database account the session
logged in as, which a user cannot spoof. That’s the safe kind of input. The same rule applies here as in the
redaction post: base the decision on a session fact the
user can’t set themselves.
In the real world, app servers often connect through one pooled database account and identify the end user
separately. There the standard pattern is an application context: a logon trigger (or the connection
pool) sets a context attribute like app_ctx.region for the session, and the policy function reads
SYS_CONTEXT('APP_CTX','REGION'). Done right, the context is set by a trusted package the user can’t call
directly, so it’s just as unforgeable as SESSION_USER. Done wrong — keying off a client identifier the
application sets for convenience — it’s a lock anyone can open by setting the same value. Same trap, same fix.
Who bypasses the policy
Two kinds of session are not subject to VPD:
SYS(andSYSDBAconnections) are always exempt — they see the raw table.- Any user holding the
EXEMPT ACCESS POLICYsystem privilege bypasses every VPD policy in the database, regardless of any function. This is how you build a “sees everything” role — a manager, a reporting account — and it is the exact analog ofEXEMPT REDACTION POLICY. It is also a master key: grant it to as few accounts as possible and audit who holds it, because one such account defeats every row policy you’ve written.
-- MGR sees every row, in every region, on every VPD-protected table:
GRANT EXEMPT ACCESS POLICY TO mgr;
flowchart TD
Q["Query from REP_EAST<br/>SELECT ... FROM orders"] --> P{"VPD policy on ORDERS:<br/>is the session EXEMPT?"}
P -->|"no — subject (REP_EAST)"| F["Policy function runs<br/>returns region = 'EAST'"]
F --> A["Oracle appends the predicate<br/>...WHERE (region = 'EAST')"]
A --> R["Base table scanned WITH the predicate<br/>→ only EAST rows return"]
P -->|"yes — EXEMPT ACCESS POLICY / SYS"| C["No predicate added<br/>→ all rows return"] Now prove it
Put a policy like the one above on an ORDERS table of known shape — 3,000 rows, deterministically split
1,800 EAST / 1,200 WEST — then read it back as three users: REP_EAST, REP_WEST, and a MGR who holds
EXEMPT ACCESS POLICY:
-- each of these is the SAME statement; only the connected user differs
SELECT COUNT(*), SUM(amount) FROM vpdown.orders;
| user | rows returned | regions seen | SUM(amount) | WHERE id = 3 (a WEST row) |
|---|---|---|---|---|
REP_EAST | 1,800 | EAST only | 180,000 | 0 rows |
REP_WEST | 1,200 | WEST only | 120,000 | 1 row |
MGR (exempt) | 3,000 | EAST + WEST | 300,000 | 1 row |
Read that table slowly, because every column is a separate proof. The row counts show each rep is confined
to their region. The SUM(amount) shows the predicate is applied to aggregates too — REP_EAST can’t
even learn the company-wide total, only their own 180,000. And the WHERE id = 3 probe is the one that
kills the “I’ll just ask for the row directly” idea: id 3 is a WEST order, and when REP_EAST asks for it by
primary key, Oracle appends AND region = 'EAST' and returns nothing. There is no query shape that widens the
result. MGR, holding the exempt privilege, sees all 3,000 — the intended bypass, working as designed.
Don’t take my word for it — run it. The VPD lab stands up an Oracle Database Free container, builds
ORDERS(3,000 rows, 1,800 EAST / 1,200 WEST) behind aDBMS_RLSpolicy, and reads it back asREP_EAST,REP_WEST, and anEXEMPTMGR. It asserts each rep sees only its region on the row count, onSUM(amount), and on a targeted by-id lookup, and that the exempt manager sees all 3,000. If any reader ever sees a row it shouldn’t — or the aggregates aren’t filtered — the run fails. It’s proven on every CI push.
Don’t forget the other three verbs
The lab policy covers SELECT, which is where most people start. But row-level security that only filters
reads has a hole: what stops REP_EAST from inserting a WEST order, or updating an EAST order to move
it to WEST? By default, nothing. VPD policies apply to the statement types you name, and for writes you almost
always want the full set:
DBMS_RLS.ADD_POLICY(
object_schema => 'VPDOWN', object_name => 'ORDERS',
policy_name => 'ORDERS_REGION_POLICY',
function_schema => 'VPDOWN', policy_function => 'ORDERS_RLS',
statement_types => 'SELECT,INSERT,UPDATE,DELETE',
update_check => TRUE); -- reject writes that would land a row outside the session's slice
update_check => TRUE is the important flag: without it, the predicate filters which rows an UPDATE or
DELETE can see, but an INSERT (or an UPDATE that changes region) could still write a row the session
wouldn’t be allowed to read back. With it on, Oracle re-checks the new row against the predicate and raises an
error if it falls outside — so a rep can’t stash a row in another region.
What teams get wrong
- Using a view (or the application) as the security boundary. A view filters only if everyone goes through it; grant the base table for one report and it’s over. Application-tier filtering is worse — every new client, script, or BI tool is a fresh way around it. VPD moves the rule into the table so there’s one place, enforced for every path.
- Filtering reads but not writes. A
SELECT-only policy lets a user insert or move rows outside their slice. AddINSERT,UPDATE,DELETEwithupdate_check => TRUEwhen the rows are meant to stay put. - Keying the predicate off something spoofable. A
CLIENT_IDENTIFIERor context value the application sets for convenience is a lock the user can pick by setting the same value. Key offSESSION_USER, an enabled role, or an application context populated by a trusted package the user can’t call. - Forgetting the exempt paths.
EXEMPT ACCESS POLICYholders andSYSsee everything; a full Data Pump export run by such an account carries every row; the object owner’s own access can bypass the policy. Audit who is exempt and treat privileged exports as sensitive — the same discipline asEXEMPT REDACTION POLICY. - A heavy or non-deterministic policy function. The function runs as part of query parsing/execution, so
a slow lookup or a function that returns different predicates unpredictably hurts performance and
plan-sharing. Keep it cheap, and use the right policy type (
STATIC,SHARED_STATIC,CONTEXT_SENSITIVE) so Oracle caches the predicate instead of re-running the function on every call. - Expecting VPD to hide a column. VPD filters rows. If you need to blank out a column’s value for some
users, that’s Data Redaction (or VPD’s column-sensitive
mode with
sec_relevant_cols, which applies the row predicate only when a sensitive column is selected). Match the tool to whether you’re hiding a row or a value. - Reaching for VPD when you need labels or a full model. For classification-driven access (secret/confidential/public) or complex, centrally-managed policies across many tables, Oracle Label Security and Real Application Security exist and are built for that. VPD is the lightweight, write-your-own-predicate option — perfect for “each tenant sees their own rows,” heavier machinery for more.
Frequently asked questions
What is Oracle Virtual Private Database (VPD)?
Virtual Private Database, also called Fine-Grained Access Control and implemented through the DBMS_RLS package, is an Oracle feature that enforces row-level (and optionally column-sensitive) security by attaching a policy to a table, view, or synonym. You write a policy function that returns a SQL predicate, and Oracle transparently appends that predicate to every query the session runs against the object, so each session sees only the rows the predicate allows. Because the rule lives on the base object rather than in a view or the application, it is enforced for every access path — ad-hoc SQL, reports, exports, and joins alike. It requires no changes to the application or the SQL text.
How is VPD different from using a view to hide rows?
A view is a separate object: it filters rows only for sessions that query the view, so the protection disappears the moment someone is granted and queries the underlying base table (a report, an export, an ad-hoc query). VPD attaches the predicate to the base table itself, so every statement against that table — through any path — carries the filter. There is nothing to route around. Views are fine for convenience and shaping data; VPD is the right tool when the row restriction is a security boundary that must hold no matter how the table is reached.
What is a VPD policy function and what must it return?
A VPD policy function is a PL/SQL function that takes two arguments — the schema and object name — and returns a VARCHAR2 containing a SQL predicate (the text that would go after WHERE), or an empty/NULL string to add no restriction. Oracle calls it for each session that queries the protected object and appends the returned predicate to the statement. The function typically bases the predicate on an unspoofable session fact such as SYS_CONTEXT('USERENV','SESSION_USER') or an application context set at logon. It should be lightweight and, ideally, deterministic for a given session so Oracle can cache the predicate using an appropriate policy type (STATIC, SHARED_STATIC, or CONTEXT_SENSITIVE).
Who is exempt from VPD policies?
The SYS user (and SYSDBA connections) are always exempt and see the unfiltered table. In addition, any user granted the EXEMPT ACCESS POLICY system privilege bypasses every VPD policy in the database, regardless of the policy functions. That privilege is how you build a role that sees all rows — for example a manager or a reporting account — and it is the exact analog of EXEMPT REDACTION POLICY for Data Redaction. Because a single exempt account defeats every row policy, grant it to as few users as possible and audit who holds it. Note also that a full Data Pump export run by a privileged or exempt account carries every row, so treat such exports as sensitive.
Does VPD apply to INSERT, UPDATE, and DELETE, or only SELECT?
VPD applies to whatever statement types you name in DBMS_RLS.ADD_POLICY. A SELECT-only policy filters reads but does nothing to stop a user inserting a row outside their slice or updating a row to move it there. For a complete boundary, add INSERT, UPDATE, and DELETE to statement_types, and set update_check => TRUE. The update_check flag makes Oracle re-evaluate the predicate against the new or changed row and reject the write if the row would fall outside what the session is allowed to see, so a user cannot write rows they could not read back. For INSERT and UPDATE you can also supply separate policy functions if the write rule differs from the read rule.
What is the difference between VPD, Data Redaction, and Oracle Label Security?
They protect different things. VPD (Virtual Private Database) filters ROWS: a per-session predicate decides which rows a query returns, and the row is simply absent for users who should not see it. Data Redaction masks COLUMN VALUES at read time: the row is still returned, but a protected column comes back masked, partial, or random — the data on disk is unchanged. Oracle Label Security (OLS) is a packaged, classification-driven form of row-level security built on VPD, where each row carries a sensitivity label and access is decided by comparing the label to the user's clearance, managed centrally rather than by hand-written predicates. A mature design can combine them: VPD or OLS to control which rows, redaction to mask sensitive columns within the rows a user can see, and TDE to encrypt the whole thing at rest.
Does VPD hurt query performance?
VPD adds a predicate to the query and calls the policy function, so the cost has two parts: running the function and executing the extra filter. The function overhead is controlled by the policy type — STATIC and SHARED_STATIC cache the predicate and run the function once, CONTEXT_SENSITIVE re-evaluates only when a relevant context changes, and DYNAMIC (the default) runs it on every parse/execute — so choosing the right type for how often the predicate actually changes matters. The predicate itself is just SQL: if it is selective and supported by an index (for example region = 'EAST' with an index on region), it can even reduce work; if it is written poorly it adds a filter like any other WHERE clause. Keep the function cheap and deterministic, index the columns the predicate uses, and pick the policy type that matches your predicate's volatility.
Is VPD available in Oracle Database Free and Standard Edition?
VPD is a feature of Oracle Database Enterprise Edition, and it is included with EE rather than being a separately licensed option (unlike Advanced Security, which covers TDE and Data Redaction). It is available in Oracle Database Free, which is built on the Enterprise Edition feature set, which is why you can build and run the companion lab on the zero-license Free image with nothing but Docker. It is not part of Standard Edition. As always, confirm your specific edition and entitlement before relying on it in production, since licensing depends on your platform and can change.
Row-level security is one layer of the same data-protection discipline as the rest: Data Redaction masks the values within the rows a user can see, TDE makes the files unreadable when they’re stolen, unified auditing records who looked, and the hardening checklist and least-privilege grants decide who can connect at all. VPD decides which rows each session is even allowed to see — and, unlike a view, it decides it on the base table so there’s no way around it. Put the rule where the data lives, key it off something the user can’t forge, remember the write path, and then prove it the way that ends the argument: with the VPD lab, where three users query one table and each sees only what they should.
Have a question or some feedback?
I write here in a personal capacity and enjoy comparing notes with other Oracle folks. Say hello.
Get in touch