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_filterblocks combine with AND. - The attribute value uses Looker filter syntax, so a value of
West,Centralmatches either,%wildcards work, and-Westexcludes. 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
| Requirement | Use |
|---|---|
| Restrict rows by one attribute value (tenant, region) | access_filter |
| Restrict rows with complex logic or lookups | sql_always_where with _user_attributes |
| Hide explores, joins or fields from some users | access_grant + required_access_grants |
| Restrict which models a role sees | Model 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_filterdoes 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
regioncolumn,access_filteronregioncannot 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_runnerpermission. - 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_recipientwhere appropriate.
Testing the boundary
- Create test users (or embed users) for two tenants and one admin, with attributes set accordingly.
- Use Sudo (Admin > Users) to become each user and run the same explore, dashboard and scheduled delivery. Compare row counts.
- Through the API, run
run_inline_queryas each test user's key and confirm the filter is present in the returnedsqlformat. - Try to reach the other tenant's data through every path: a dashboard URL, a Look, a drill, a merged result, the
system__activitymodel, and a Gemini question if enabled. - 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.