+1 (726) 227-2971

Row-Level Security Patterns: access_filter, User Attributes, and required_access_grants

Looker's roles and permissions decide what a user can do. Row-level security decides which rows they can see, and it has to live in the model so it applies to every path: dashboards, explores, scheduled deliveries, embeds, the API and Gemini. This tutorial covers the four mechanisms Looker provides, when to use each, how user attributes tie them together, and how to test that the boundary holds.

User attributes: the foundation

A user attribute is a named value attached to each user (or group), set by an admin and readable by LookML. Define one in Admin > Users > User Attributes:

  • Name: region (used in LookML), with a label.
  • Data type: string, number, or advanced filter types for multi-value filters.
  • User access: None for anything security depends on, so users cannot view or edit their own value.
  • Default value: leave empty for security attributes. A missing value should mean no data, not all data.
  • Values: set per user, or per group (group values win by precedence order), or supplied in an embed URL for embed users.

Security attributes are typically populated from your identity provider through SAML or OIDC attribute mapping, from an HR sync through the API (set_user_attribute_user_value), or from your application in signed embeds.

Mechanism 1: access_filter

The standard tool. Declared on an explore, it adds a WHERE clause on a field using the user's attribute value, for every query through that explore.

explore: orders {
  access_filter: {
    field: users.region
    user_attribute: region
  }
  access_filter: {
    field: orders.brand
    user_attribute: allowed_brands   # advanced filter type: "Acme,Globex" or "Acme%" both work
  }

  join: users {
    sql_on: ${orders.user_id} = ${users.id} ;;
    relationship: many_to_one
  }
}

Properties worth knowing:

  • Multiple access_filter blocks combine with AND.
  • The attribute value uses Looker filter syntax, so a value of West,Central matches either, % wildcards work, and -West excludes. Use the Advanced Filter (String) type for this.
  • If the attribute has no value for the user, the filter produces no rows. This is the safe default; keep it that way by not setting a default value.
  • Admins are not exempt. Give admin users an attribute value of % if they should see everything.
  • The filtered field can be in a joined view, as above; the join is forced into every query.

Use access_filter for the common case: tenant, region, department, brand.

Mechanism 2: sql_always_where

When the restriction is more complex than "field equals attribute" (a subquery, an OR, a date window), use sql_always_where on the explore with Liquid to read the attribute:

explore: orders {
  sql_always_where:
    ${orders.created_date} >= DATE_SUB(CURRENT_DATE(), INTERVAL {{ _user_attributes['history_days'] }} DAY)
    AND (
      ${users.region} IN (SELECT region FROM analytics.manager_regions
                          WHERE manager_email = '{{ _user_attributes['email'] }}')
      OR '{{ _user_attributes['is_global_viewer'] }}' = 'yes'
    ) ;;
}

_user_attributes['name'] is the Liquid accessor. Quote string attributes and treat them as untrusted when users can edit them (another reason to set User access: None). Unlike access_filter, sql_always_where is invisible in the Explore's filter UI and does not depend on Looker filter syntax, which makes it both more flexible and easier to get wrong; keep the logic readable and test it.

sql_always_having does the same for aggregate conditions, and always_filter / conditionally_filter are not security features: users can change them.

Mechanism 3: access_grant and required_access_grants

Row filters restrict data; access grants restrict objects: an explore, a join, a view or a field becomes invisible unless the user's attribute matches. Declare the grant at model level, reference it where it applies:

# model level
access_grant: can_see_pii {
  user_attribute: pii_access
  allowed_values: ["yes"]
}
access_grant: finance_team {
  user_attribute: department
  allowed_values: ["Finance", "Executive"]
}

explore: payroll {
  required_access_grants: [finance_team]
}

explore: orders {
  join: users {
    sql_on: ${orders.user_id} = ${users.id} ;;
    relationship: many_to_one
    required_access_grants: [can_see_pii]   # the whole join disappears for others
  }
}

view: users {
  dimension: email {
    sql: ${TABLE}.email ;;
    required_access_grants: [can_see_pii]
  }
}

A user without can_see_pii does not see the email field or the users join at all; dashboards that include the field render the tile with an error for that user rather than leaking it. Grants listed together are ANDed. Remember that grants only restrict within a model the user can already access; which models a user sees is set by the model set on their role.

Mechanism 4: embed users and external_group_id

For customer-facing embeds, the signed embed URL (or cookieless session) supplies user_attributes for the embed user, and access_filter in the model enforces them. Folder access is scoped by external_group_id. The model does not know the difference between an embed user and a regular one, which is exactly the point: one set of rules. Details in Embedding Looker in 2026.

Choosing

RequirementUse
Restrict rows by one attribute value (tenant, region)access_filter
Restrict rows with complex logic or lookupssql_always_where with _user_attributes
Hide explores, joins or fields from some usersaccess_grant + required_access_grants
Restrict which models a role seesModel sets on roles (Admin)
Restrict content (folders, dashboards)Folder permissions and groups (Admin)

Most production models use access_filter for tenancy plus a couple of access grants for PII. Reach for sql_always_where only when the first two cannot express the rule.

Things that break row-level security

  • PDTs and aggregate tables are built by Looker's PDT process, not the querying user, so access_filter does not apply at build time; the query against the PDT is still filtered, as long as the filtered field is in the derived table. Never push the security filter into the derived table's SQL.
  • Derived tables that pre-aggregate away the filter column. If a rollup has no region column, access_filter on region cannot apply; Looker refuses to use an aggregate table that would bypass an access filter, but a hand-written PDT explore with no such column simply leaks.
  • Merged results and SQL Runner. SQL Runner bypasses the model entirely; restrict it with the use_sql_runner permission.
  • User-editable attributes. An attribute with User access: Edit is a text box the user controls.
  • Default attribute values. A default of % on a security attribute gives every new user everything.
  • Schedules. A schedule runs as its owner; content with a broad-access owner delivers broad data to whoever receives it. Use run_as_recipient where appropriate.

Testing the boundary

  1. Create test users (or embed users) for two tenants and one admin, with attributes set accordingly.
  2. Use Sudo (Admin > Users) to become each user and run the same explore, dashboard and scheduled delivery. Compare row counts.
  3. Through the API, run run_inline_query as each test user's key and confirm the filter is present in the returned sql format.
  4. Try to reach the other tenant's data through every path: a dashboard URL, a Look, a drill, a merged result, the system__activity model, and a Gemini question if enabled.
  5. Put steps 2 and 3 into a LookML data test or a Spectacles run so the check repeats in CI (see CI for LookML).

Security reviews of this kind are part of every Looker health check we run, and the most common finding is still a security attribute with a default value. If you would like yours reviewed, contact us.