Skip to content
R.

How do I use CREATE POLICY per-command USING vs WITH CHECK to allow safe INSERTs that set owner while blocking later owner UPDATEs?

Use separate PostgreSQL row-security policies for INSERT and UPDATE so a row can be created with an owner and later edited without making the owner column mutable.

About 17 min readComments

PostgreSQL row security is often described as a way to hide rows, but the more precise use is to control which row versions may exist. That matters when a table has an owner column that should be assigned when the row is created and then treated as immutable afterward. The question is not whether the table can be protected at all; it is whether creation and later modification can be given different rules without broadening privileges.

The answer is to separate the commands. USING applies to existing rows and decides whether a row is visible or targetable. WITH CHECK applies to the row that would be stored and decides whether that row is allowed to exist. Once those responsibilities are kept apart, the ownership rule becomes straightforward: INSERT validates the proposed owner, UPDATE permits edits to owned rows, and the updated row must still satisfy the ownership condition. The article and fixture below stay on that boundary and do not pretend the policy is a substitute for the rest of the authorization model.

What this policy split is actually protecting

The useful problem is narrower than “protect the table.” You want a table where a row can be created with an owner value, but that owner value should not become another field the caller can rewrite later. In other words, the row needs one rule at creation time and a different rule after it exists. A single broad privilege grant is too loose for that contract, because it treats ownership like any other editable column. A single broad denial is too strict, because it blocks ordinary updates along with the dangerous ones.

PostgreSQL row security is a good fit precisely because it separates those decisions by command. When row security is enabled, normal access to a table must be allowed by a policy, and policies can be attached to SELECT, INSERT, UPDATE, DELETE, or ALL. The important detail is that the policy expression is not one undifferentiated filter. USING controls which existing rows are visible or targetable, while WITH CHECK controls whether the row that would be stored is acceptable. That split lets the database enforce one invariant for a new row and another invariant for an existing row being modified.

The resulting contract is simple to state and easy to test: a caller may insert a row that names itself as owner, may update other fields on rows it already owns, and may not transfer ownership by update. That is not a full identity system and it does not remove the need for proper table privileges, but it does let the table encode an ownership boundary that the application is not trusted to preserve by convention alone.

main.mjs / javascript
export function createTable({ primaryKey = 'id' } = {}) {
  const state = {
    rlsEnabled: false,
    rows: [],
    nextId: 1,
    policies: [],
    primaryKey,
  };

  function enableRLS() {
    state.rlsEnabled = true;
  }

  function addPolicy(policy) {
    state.policies.push({ kind: 'PERMISSIVE', roles: null, for: 'ALL', ...policy });
  }

  function matchesRole(policy, user) {
    return !policy.roles || policy.roles.includes(user);
  }

  function applicablePolicies(command) {
    return state.policies.filter((p) => p.for === 'ALL' || p.for === command);
  }

  function evalUsing(policy, row, ctx) {
    if (!matchesRole(policy, ctx.current_user)) return false;
    if (typeof policy.using !== 'function') return false;
    return Boolean(policy.using(row, ctx));
  }

  function evalWithCheck(policy, newRow, ctx) {
    if (!matchesRole(policy, ctx.current_user)) return false;
    const check = policy.withCheck || policy.using;
    if (typeof check !== 'function') return false;
    return Boolean(check(newRow, ctx));
  }

  function combine(command, row, newRow, ctx) {
    if (!state.rlsEnabled) return { visible: true, writable: true };

    const policies = applicablePolicies(command);
    if (policies.length === 0) return { visible: false, writable: false };

    const permissive = policies.filter((p) => p.kind !== 'RESTRICTIVE');
    const restrictive = policies.filter((p) => p.kind === 'RESTRICTIVE');

    const visible = (() => {
      if (command === 'INSERT') return true;
      const perm = permissive.length > 0 && permissive.some((p) => evalUsing(p, row, ctx));
      const restr = restrictive.every((p) => evalUsing(p, row, ctx));
      return perm && restr;
    })();

    const writable = (() => {
      if (newRow == null) return visible;
      if (command === 'DELETE') return visible;
      const perm = permissive.length > 0 && permissive.some((p) => evalWithCheck(p, newRow, ctx));
      const restr = restrictive.every((p) => evalWithCheck(p, newRow, ctx));
      return perm && restr;
    })();

    return { visible, writable };
  }

  function insertRow(current_user, row) {
    const ctx = { current_user };
    const newRow = { ...row };
    if (!(state.primaryKey in newRow)) newRow[state.primaryKey] = state.nextId++;

    const result = combine('INSERT', null, newRow, ctx);
    if (!result.writable) {
      const err = new Error('row-level security denied INSERT');
      err.code = 'RLS_INSERT_DENIED';
      throw err;
    }

    state.rows.push(newRow);
    return { ...newRow };
  }

  function updateRow(current_user, keyValue, changes) {
    const ctx = { current_user };
    const idx = state.rows.findIndex((r) => r[state.primaryKey] === keyValue);
    if (idx === -1) {
      const err = new Error('row not found');
      err.code = 'NOT_FOUND';
      throw err;
    }

    const oldRow = state.rows[idx];
    const checkVisible = combine('UPDATE', oldRow, null, ctx);
    if (!checkVisible.visible) {
      const err = new Error('row-level security denied UPDATE USING');
      err.code = 'RLS_UPDATE_USING_DENIED';
      throw err;
    }

    const newRow = { ...oldRow, ...changes };
    const checkWritable = combine('UPDATE', oldRow, newRow, ctx);
    if (!checkWritable.writable) {
      const err = new Error('row-level security denied UPDATE WITH CHECK');
      err.code = 'RLS_UPDATE_WITHCHECK_DENIED';
      throw err;
    }

    state.rows[idx] = newRow;
    return { ...newRow };
  }

  function getRows() {
    return state.rows.map((row) => ({ ...row }));
  }

  return Object.freeze({ enableRLS, addPolicy, insertRow, updateRow, getRows });
}

SourcesPostgreSQL Row Security Policies (opens a new tab)PostgreSQL CREATE POLICY (opens a new tab)

Make INSERT accept only rows that already match the caller

The INSERT side is where the ownership rule starts. PostgreSQL documents that INSERT policies use WITH CHECK, not USING, because there is no pre-existing row to filter. The policy evaluates the row that would be inserted. If that row satisfies the expression, the insert succeeds; if it does not, the command fails. That is exactly what you want when the rule is “the inserted owner must be the current user.”

This is the part of the contract that often gets blurred in application code. A service can send an INSERT with an owner field and still rely on the database to reject rows that claim the wrong owner. The database is not deciding whether the caller is authenticated; it is deciding whether the new row shape is acceptable. Those are different questions. Authentication belongs to the session or role setup. Ownership enforcement belongs in the policy expression.

The benefit of keeping that logic at the database boundary is that every path that reaches the table gets the same check. A background worker, a request handler, or a one-off admin script all face the same condition. That matters because ownership bugs are often introduced by “special” code paths that bypass the normal application guardrails. A policy on the table removes the need to remember which caller path was careful and which one was not. The row only exists if the proposed owner matches the policy.

The supported documentation also makes an important limit explicit: if no applicable policy exists, row security is default-deny. That is useful safety, but it also means the INSERT rule is not a substitute for the rest of the permission model. You still need the proper table privilege grants and a role model that maps a request to the user identity you intend to compare against.

SourcesPostgreSQL CREATE POLICY (opens a new tab)PostgreSQL Row Security Policies (opens a new tab)

Use UPDATE USING to target the owner’s row, and WITH CHECK to block owner tampering

UPDATE is where the policy split becomes architectural rather than just syntactic. PostgreSQL says an UPDATE policy can include both USING and WITH CHECK. USING determines which existing rows the command may see and attempt to modify. WITH CHECK determines whether the row after the change is allowed to be stored. Those are different gates, and a correct ownership rule needs both of them.

The revised point is important: if you define an UPDATE policy with USING only, PostgreSQL does not leave the updated row unchecked. The engine reuses USING as the check for UPDATE when no WITH CHECK is supplied. So the dangerous loophole is not “UPDATE with USING only lets owner changes through.” The documented behavior is the opposite: a USING-only UPDATE policy still applies to the new row, which means an attempted owner change still fails if the USING predicate is written as an ownership predicate. In other words, PostgreSQL already prevents the specific mistake the earlier draft overstated.

What still matters is choosing the right expression. If the policy says “the row owner must equal current_user,” then that predicate should be true for both the existing row and the proposed row. That lets the owner change non-owner columns while denying a rewrite that transfers the row to somebody else. The policy does not need to compare OLD and NEW like a trigger. It needs to describe the acceptable ownership condition before and after the update, and PostgreSQL’s UPDATE semantics carry that same rule through both phases.

That is the right mental model: update policies are about whether the caller may reach the row and whether the updated row still belongs to the permitted set. They are not a mutation hook. Once that is clear, the owner column is protected by the same invariant whether the caller is changing a note field or trying to claim a different owner.

SourcesPostgreSQL CREATE POLICY (opens a new tab)PostgreSQL Row Security Policies (opens a new tab)

Why separate command policies are clearer than one broad rule

You can express the ownership rule with a single ALL policy, but that is a convenience tradeoff, not a clarity win. PostgreSQL allows an ALL policy to participate in both the selection side and the modification side, and when no WITH CHECK is defined the USING expression is reused for the check. That is compact, but compactness is not the same thing as explicitness. When you are protecting an ownership column, the command boundaries are the useful part of the design.

Separate policies make the intent legible. One INSERT policy says who may create a row and what owner value it may carry. One UPDATE policy says which existing rows may be touched and what owner value the resulting row must still have. When those are split, each command can evolve independently. If you later need to loosen inserts for a service role but keep owner transfers blocked, you can change the INSERT rule without accidentally changing what an UPDATE means. If you later need to let an admin role rewrite rows but not ordinary users, you can add a role-specific policy instead of bending one expression until it covers every case.

The implementation in the supplied example also reflects that separation. The table model keeps a list of policies, a row-security switch, and distinct insert and update paths. Insert checks only the new row against the INSERT rule. Update first checks whether the existing row is visible to the caller through USING, then checks the proposed row through WITH CHECK. That is a faithful conceptual translation of the documented split, even though it is an application-side model rather than a PostgreSQL server. The value of the model is not that it replaces the database; it is that it isolates the two gates so the reader can see why a policy that feels “close enough” is still wrong if it collapses selection and storage into one rule.

This is also where a common incomplete fix fails. If you only add an INSERT policy and keep UPDATE broad, ownership can be rewritten later. If you only restrict UPDATE visibility and forget the new-row check, the caller can still preserve access to the row while changing the owner column. The real answer needs both halves of the command-specific contract.

SourcesPostgreSQL CREATE POLICY (opens a new tab)PostgreSQL Row Security Policies (opens a new tab)

What the provided fixture proves, and what it deliberately does not

The supplied test fixture is useful because it exercises the exact boundary the article cares about. It shows a successful INSERT when the owner matches the current user, a rejected INSERT when the caller tries to claim somebody else’s owner value, a permitted UPDATE that changes a non-owner field, a rejected UPDATE that tries to transfer ownership, and a rejected UPDATE by a non-owner that cannot even reach the row. Those cases are the right ones because they distinguish “new row accepted,” “existing row visible,” and “new row still acceptable after modification.”

That sequence matters more than a single happy-path assertion. A passing insert alone would not prove that later owner changes are blocked. A failed owner transfer alone would not prove that ordinary edits remain possible. A denied non-owner update alone would not prove that the INSERT path is safe. The fixture’s point is that the policy shape is only convincing when the commands are tested in the order a real application uses them. The database contract is not just “some rows are denied”; it is “the right rows are denied for the right reasons.”

The fixture still has clear limits. It is a local model, not a live PostgreSQL session. It does not execute CREATE POLICY, it does not switch database roles, and it does not test planner behavior, leakproof functions, or the interaction with table privileges. It also does not prove concurrency semantics, which the PostgreSQL documentation warns can matter when a policy consults other tables or other rows. Those limits are fine for the purpose of this article, because the question is about the policy split itself, not about reproducing every edge of row security in a running cluster.

That distinction should stay visible to the reader. The fixture validates the shape of the rule and the meaning of the two clauses. The PostgreSQL documentation supplies the authoritative behavior of the actual engine. Together, they answer the practical question: use INSERT WITH CHECK to validate the proposed owner, and use UPDATE USING together with the same ownership predicate to allow ordinary edits while preventing owner reassignment.

SourcesPostgreSQL CREATE POLICY (opens a new tab)PostgreSQL Row Security Policies (opens a new tab)

Limits, alternatives, and when this pattern is too small

The ownership rule is a good fit for row security when the invariant is local to the row itself. If the decision depends on other tables, time windows, or complex workflow state, the policy can still do it, but the cost rises quickly. The PostgreSQL documentation explicitly notes that policy expressions can consult other rows or tables, and that those checks can introduce race conditions if they rely on changing state. So while the owner pattern is clean, it is not a universal substitute for business logic or for a transaction design that coordinates multiple tables.

There are also cases where row security is the wrong layer entirely. If you need to decide whether a request may create a row based on an external approval process, a policy on the table is not enough by itself. If you need to transfer ownership as part of an administrative workflow, you may need a separate administrative policy rather than trying to overload the user-facing rule. If the ownership semantics are really about application authorization rather than row ownership, then the database should probably enforce a more general role boundary instead of a per-row attribute.

Even within the narrow owner-column problem, the policy is only part of the contract. The table owner normally bypasses row security unless FORCE ROW LEVEL SECURITY is used. Superusers and roles with BYPASSRLS also bypass it. That means the pattern is for ordinary application sessions, not for every privileged actor in the system. The policy protects the normal path, but privileged maintenance still needs its own controls and review.

For that reason, the best mental model is not “RLS makes ownership safe forever.” It is “RLS lets the table enforce one important invariant consistently for ordinary users, and the database documentation tells you exactly which clause controls which phase of the command.” That is a strong answer when the problem is row ownership. It is not a full authorization architecture, and it should not be treated like one.

main.test.mjs / javascript
import test from 'node:test';
import assert from 'node:assert/strict';
import { createTable } from './main.mjs';

function setup() {
  const table = createTable();
  table.enableRLS();
  table.addPolicy({
    name: 'insert_own_row',
    for: 'INSERT',
    withCheck: (newRow, ctx) => newRow.owner === ctx.current_user,
  });
  table.addPolicy({
    name: 'update_own_rows',
    for: 'UPDATE',
    using: (row, ctx) => row.owner === ctx.current_user,
    withCheck: (newRow, ctx) => newRow.owner === ctx.current_user,
  });
  return table;
}

test('INSERT accepts a row whose owner matches the current user', () => {
  const table = setup();
  const inserted = table.insertRow('alice', { owner: 'alice', note: 'draft' });

  assert.equal(inserted.id, 1);
  assert.equal(inserted.owner, 'alice');
  assert.deepEqual(table.getRows(), [{ id: 1, owner: 'alice', note: 'draft' }]);
});

test('INSERT rejects a row that claims a different owner', () => {
  const table = setup();

  assert.throws(
    () => table.insertRow('bob', { owner: 'alice', note: 'pretend ownership' }),
    (err) => err && err.code === 'RLS_INSERT_DENIED'
  );

  assert.deepEqual(table.getRows(), []);
});

test('UPDATE allows ordinary edits but blocks owner reassignment', () => {
  const table = setup();
  const row = table.insertRow('alice', { owner: 'alice', note: 'draft' });

  const updated = table.updateRow('alice', row.id, { note: 'published' });
  assert.equal(updated.owner, 'alice');
  assert.equal(updated.note, 'published');

  assert.throws(
    () => table.updateRow('alice', row.id, { owner: 'mallory' }),
    (err) => err && err.code === 'RLS_UPDATE_WITHCHECK_DENIED'
  );

  assert.deepEqual(table.getRows(), [{ id: 1, owner: 'alice', note: 'published' }]);
});

test('UPDATE with only USING reuses USING as the check, so owner changes are still blocked', () => {
  const table = createTable();
  table.enableRLS();
  table.addPolicy({
    name: 'update_own_rows_using_only',
    for: 'UPDATE',
    using: (row, ctx) => row.owner === ctx.current_user,
  });
  table.addPolicy({
    name: 'insert_anything_for_fixture',
    for: 'INSERT',
    withCheck: () => true,
  });

  const row = table.insertRow('alice', { owner: 'alice', note: 'draft' });

  assert.throws(
    () => table.updateRow('alice', row.id, { owner: 'mallory' }),
    (err) => err && err.code === 'RLS_UPDATE_WITHCHECK_DENIED'
  );

  assert.deepEqual(table.getRows(), [{ id: 1, owner: 'alice', note: 'draft' }]);
});

SourcesPostgreSQL CREATE POLICY (opens a new tab)PostgreSQL Row Security Policies (opens a new tab)

Share LinkedIn Email Subscribe

Discussion

Leave a comment

Comments appear after review. No email needed.

Image preview

Follow the blog

Choose email updates or your favorite RSS reader.

The weekly email

New articles on code, design, and AI. Fridays, only when there is something new.

By subscribing, you agree to weekly blog emails.
Confirm in your inbox. Unsubscribe anytime. Privacy

Use OpenRSS

Preview the feed, then choose a reader to subscribe.

Open in OpenRSS (opens a new tab)

Already have a reader?

Paste this link into your reader's Add feed option.

Prefer a reader? RSS does not require an email address. View XML feed