DEV Community

Vivek Kumar
Vivek Kumar

Posted on

Row-Level Security for Embedded Dashboards: A Practical Postgres Guide

You just shipped your first embedded dashboard. Customers can log into your SaaS, click "Analytics," and see live charts of their data. Great. Then the support emails roll in.

"Why can my finance lead see the executive comp report?"
"Our intern can see everyone's pipeline."
"This customer can see another customer's invoice in a CSV export — please fix immediately."

Embedded analytics has a security model that is subtly different from your normal app authorization. Dashboards run many queries you didn't write — exports, drill-downs, ad-hoc filters, AI-generated SQL. The "just add a WHERE clause" trick that works in your handcrafted API endpoints does not work here, because there is no single endpoint anymore. Every query is a potential leak.

This article is about how to use Postgres Row-Level Security (RLS) to make embedded dashboards safe by default. We'll cover the embed-token-to-session-context flow, per-user roles within a tenant, column masking, performance, and the connection pooler gotcha that quietly breaks RLS in production.

Why "WHERE tenant_id = ?" isn't enough

The classic pattern is to stamp every row with a tenant_id and add WHERE tenant_id = :current_tenant to every query. That works when you write every query.

But in an embedded dashboard:

  • Users can build their own queries through the UI.
  • An AI assistant might generate SQL on the fly.
  • CSV exports run separate queries.
  • A drill-down chart may join three tables, any one of which could be missing the filter.
  • Power users might paste raw SQL into a query console.

You don't want the safety of your customers' data to depend on every developer (and every LLM) remembering the right filter. RLS moves that enforcement into the database itself — the storage layer literally refuses to return rows the current session isn't allowed to see.

The model: enable RLS, write a policy, set context per request

The three moving parts:

  1. Enable RLS on the table.
  2. Write a policy that uses a session variable to decide which rows are visible.
  3. At request time, your backend sets that session variable from the verified embed token (JWT) before running any dashboard SQL.

Here's the minimum viable setup for a SaaS with an orders table:

-- 1. Enable RLS on the table
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

-- 2. Create a policy keyed on a session-local setting
CREATE POLICY tenant_isolation ON orders
  USING (tenant_id = current_setting('app.current_tenant')::uuid);

-- 3. (Optional but recommended) force RLS even for table owners
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
Enter fullscreen mode Exit fullscreen mode

The variable name must contain a period — app.current_tenant, jwt.claims.user_id, etc. — so Postgres knows it's a custom setting and not a system one.

Then, in your backend, before running any dashboard query for that request:

SET LOCAL app.current_tenant = '8c1c...e9a3';
Enter fullscreen mode Exit fullscreen mode

SET LOCAL scopes the value to the current transaction, which is exactly what you want — it auto-clears when the transaction ends, so a stray query later in the request can't leak it.

Wiring it to the embed JWT

The embed flow most teams use looks like this. The dashboard tool (or your own iframe) receives a signed JWT minted by your backend. That JWT carries the user's tenant and role claims. Before your backend opens a query, it verifies the JWT and pushes the claims into the session:

# Pseudocode — runs at the start of every dashboard request
claims = verify_jwt(request.token)             # tenant_id, user_id, role

with db.transaction() as tx:
    tx.execute(
        "SELECT set_config('app.current_tenant', %s, true)",
        [claims["tenant_id"]],
    )
    tx.execute(
        "SELECT set_config('app.current_user', %s, true)",
        [claims["user_id"]],
    )
    tx.execute(
        "SELECT set_config('app.current_role', %s, true)",
        [claims["role"]],
    )
    # Now run the dashboard query — RLS does the rest
    result = tx.execute(dashboard_query)
Enter fullscreen mode Exit fullscreen mode

The third argument true to set_config is the transaction-local flag, equivalent to SET LOCAL. From this point on, every query the dashboard fires — including ones written by an end user or an AI — runs under the right identity.

Going beyond multi-tenancy: per-user roles inside a tenant

Most teams start with tenant_id filtering and stop there. But the real questions show up inside a tenant:

  • A sales rep should see only their own pipeline.
  • A regional manager should see their region.
  • The CRO should see everything in the company.
  • Finance should see revenue but not employee salaries.

You can express all of that in a single policy:

CREATE POLICY orders_visibility ON orders
  USING (
    tenant_id = current_setting('app.current_tenant')::uuid
    AND (
      current_setting('app.current_role') IN ('admin', 'cro')
      OR (current_setting('app.current_role') = 'manager'
          AND region = current_setting('app.current_region'))
      OR owner_user_id = current_setting('app.current_user')::uuid
    )
  );
Enter fullscreen mode Exit fullscreen mode

Now an embedded chart that runs SELECT region, SUM(amount) FROM orders GROUP BY region returns different rows for the intern, the manager, and the CRO — without a single line of application code changing.

Column-level masking for sensitive fields

RLS hides rows. Sometimes you need to hide columns — show the row, but mask one field. A common pattern is a view that wraps the base table:

CREATE VIEW orders_safe AS
SELECT
  id,
  tenant_id,
  amount,
  region,
  CASE
    WHEN current_setting('app.current_role') IN ('admin', 'finance')
      THEN customer_email
    ELSE regexp_replace(customer_email, '(^.).*(@.*$)', '\1***\2')
  END AS customer_email,
  created_at
FROM orders;
Enter fullscreen mode Exit fullscreen mode

Point your embedded dashboard at orders_safe instead of orders and the masking happens for free. Combine with RLS on the underlying table and you get row + column protection in one model.

A heads-up: views run with the privileges of their creator by default, which can bypass RLS unexpectedly. Either create the view as a non-superuser, or in modern Postgres set security_invoker = true on the view so it runs as the calling user.

Real-world example: a customer-facing usage dashboard

Say you're building a usage dashboard for an API product. Each customer should see their own request counts, latency, and errors. Inside the customer, only org admins should see per-user breakdowns:

ALTER TABLE api_events ENABLE ROW LEVEL SECURITY;

CREATE POLICY api_events_read ON api_events
  FOR SELECT
  USING (
    tenant_id = current_setting('app.current_tenant')::uuid
    AND (
      current_setting('app.current_role') = 'org_admin'
      OR user_id = current_setting('app.current_user')::uuid
    )
  );

CREATE INDEX api_events_tenant_user_idx
  ON api_events (tenant_id, user_id, created_at DESC);
Enter fullscreen mode Exit fullscreen mode

A regular user's dashboard query like:

SELECT date_trunc('hour', created_at) AS hour,
       COUNT(*) AS requests,
       AVG(latency_ms) AS avg_latency
FROM api_events
WHERE created_at > now() - interval '24 hours'
GROUP BY 1
ORDER BY 1;
Enter fullscreen mode Exit fullscreen mode

…silently returns only that user's events. An org admin gets the org-wide view from the same SQL. Your dashboard code, your AI SQL assistant, and your CSV exporter all stay identical.

Performance: don't blow up your dashboards

RLS is fast when the policy predicate is indexable. The policy tenant_id = current_setting(...) becomes part of every query plan; if tenant_id is the leading column of your indexes, you're fine. Things that quietly tank performance:

A current_setting() call that isn't marked STABLE will be re-evaluated per row. Wrap it in a STABLE SQL function and call that in the policy:

CREATE FUNCTION current_tenant() RETURNS uuid
LANGUAGE sql STABLE AS
$$ SELECT current_setting('app.current_tenant')::uuid $$;

CREATE POLICY tenant_isolation ON orders
  USING (tenant_id = current_tenant());
Enter fullscreen mode Exit fullscreen mode

Subquery-style policies that join other tables can be expensive. Prefer denormalising a tenant_id or owner_id onto the table over a policy like EXISTS (SELECT 1 FROM memberships WHERE ...).

Make sure your composite indexes lead with the column the RLS policy filters on — that's almost always tenant_id.

Common mistakes and gotchas

The connection pooler trap is the one that bites everyone. If you use PgBouncer in transaction or statement mode, SET (without LOCAL) persists across requests on the same backend connection, leaking context between tenants. Always use SET LOCAL (or set_config(..., true)) inside an explicit transaction.

Superusers and BYPASSRLS ignore policies entirely. Your application role must not be a superuser and must not have BYPASSRLS. Test as a normal application role, not as postgres.

Forgetting FORCE ROW LEVEL SECURITY means the table owner still sees everything. If your migrations or background jobs run as the owner, they bypass policies — sometimes that's what you want, but it's worth being explicit.

USING vs WITH CHECK. USING filters what's read, WITH CHECK validates what's written. If you only specify USING on an INSERT/UPDATE policy, a user could write rows tagged with another tenant's id. Specify both for write operations.

Views inherit creator privileges. If you create a view as postgres, queries against it can see everything. Either create views as a non-superuser, or set security_invoker = true (Postgres 15+).

No fallback policy. If no policy matches, the default behavior is to deny — which is good for security but can look like "the dashboard is empty" in dev. Add a clear deny-by-default policy and a verbose error path so missing context fails loudly during development, not silently in prod.

Key takeaways

Embedded dashboards run too many queries — including ones written by users and LLMs — for application-level filtering to be safe. Row-Level Security pushes the rules into the database, where every query is filtered automatically.

The pattern is small: enable RLS on the table, write a policy that reads current_setting('app.something'), and have your backend set those settings inside a SET LOCAL transaction after verifying the embed JWT. From there, you can layer per-user roles, column masking, and view-level transforms without touching dashboard code.

Get the connection-pool detail right, keep your indexes aligned with your policy predicates, and test as a non-superuser role. Do that and your embedded analytics layer becomes one of the safest parts of your stack instead of the scariest.

Over to you

If you're shipping embedded dashboards today, how are you handling tenant + per-user isolation? Are you using RLS, application-layer filtering, schema-per-tenant, or a BI tool's own permission model? Drop a comment with the pattern (and the gotchas) you've hit — especially around AI-generated SQL, where the "the LLM might forget the filter" problem makes RLS feel inevitable.

Top comments (0)