Security & Ops

Oracle Virtual Private Database: Row-Level Security You Can't Route Around


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 (and SYSDBA connections) are always exempt — they see the raw table.
  • Any user holding the EXEMPT ACCESS POLICY system 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 of EXEMPT 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"]
Where the row filter is applied — and where it isn't. For a subject session Oracle runs the policy function, gets a predicate, and appends it to the query against the base table, so only the allowed rows come back. An exempt session (EXEMPT ACCESS POLICY, or SYS) skips the function entirely and sees the whole table. There is no view in the middle to route around.

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;
userrows returnedregions seenSUM(amount)WHERE id = 3 (a WEST row)
REP_EAST1,800EAST only180,0000 rows
REP_WEST1,200WEST only120,0001 row
MGR (exempt)3,000EAST + WEST300,0001 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 a DBMS_RLS policy, and reads it back as REP_EAST, REP_WEST, and an EXEMPT MGR. It asserts each rep sees only its region on the row count, on SUM(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. Add INSERT,UPDATE,DELETE with update_check => TRUE when the rows are meant to stay put.
  • Keying the predicate off something spoofable. A CLIENT_IDENTIFIER or context value the application sets for convenience is a lock the user can pick by setting the same value. Key off SESSION_USER, an enabled role, or an application context populated by a trusted package the user can’t call.
  • Forgetting the exempt paths. EXEMPT ACCESS POLICY holders and SYS see 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 as EXEMPT 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