> For AI agents: the complete Sanity documentation index is available at [https://www.sanity.io/docs/llms.txt](https://www.sanity.io/docs/llms.txt).

# Roles

Sanity enforces user access control with roles. Roles help control resource access to datasets and documents.

You can manage access to content and settings in your Sanity Content Lake by setting roles and permissions for project members. Each member may have different roles for granular access control to your Content Lake. All projects have default roles available, but you can also create **custom roles** that define granular access to datasets and project settings. You can also use GROQ to define custom **content resources**. Content permissions are typically set to either all or individual datasets, but you can use **Tags** to group datasets that should share permissions.

Roles and permissions can be [configured through the API](https://www.sanity.io/docs/http-reference/roles), or through the project settings available at [sanity.io/manage](https://sanity.io/manage). This article will focus mainly on the latter option.

## Default roles per plan

Each plan type has access to specifically defined roles. Custom roles are available for Enterprise customers.

#### Properties

**Administrator** (All plans)

Read and write access to all datasets, with full access to all project settings.

**Viewer** (All plans)

Read-only access to all datasets, with no access to project settings. Note: viewers can comment in projects where comments are enabled.

**Editor** (Growth and Enterprise)

Read and write access to all datasets, with limited access to project settings.

Editors can modify existing datasets, but cannot create new ones.

**Developer** (Growth and Enterprise)

Read and write access to all datasets, with access to project settings for developers.

**Contributor** (Growth and Enterprise)

Read and write access to draft content within all datasets, with no access to project settings. Can write but not publish documents.

**Custom** (Enterprise)

Fully custom roles and permissions, with custom access to project settings.

## Assigning roles to members

To assign roles to users, open your project and select **Members** in the sidebar. You'll see each project member's roles listed by their name and login info.

The login info shows which sign-in method each member's account uses. A Sanity account belongs to a sign-in method rather than to an email address, so a member who signs in with a different method than the one they were invited under arrives as a separate account with no membership and no roles. To hand membership over to that account, remove the old one and send a new invitation, as described in [Account recovery](https://www.sanity.io/docs/help/account-recovery).

![Overview of project members in manage](https://cdn.sanity.io/images/3do82whm/next/554f0afe514787948ac29011ff22443a72e91450-796x385.png)

When using Single Sign-On (SSO), roles can be [automatically assigned](https://www.sanity.io/docs/developer-guides/sso-saml) to users using rules that evaluate each user’s group membership in your identity provider. Role assignment can be restricted to be set only through mapping rules, or allow for manual modification. If role assignment is restricted to be set only through mapping rules, you cannot manually change the role of a user in this screen.



![Shgows a popover alerting the user that roles are handled by identity provider and cannot be manually updated](https://cdn.sanity.io/images/3do82whm/next/9a7a942da4941567856316ac1613ac4a72ca57f9-1605x1365.png)

## Creating custom roles

**This is a paid feature**
This feature is available on certain Enterprise plans. [Talk to sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.

To define custom roles, select **Access › Roles** in your project's sidebar. You will see a list of your currently defined roles with a summary of each role's access privileges. To create a new custom role, click the button in the upper right corner.

![Shows the button indicated above](https://cdn.sanity.io/images/3do82whm/next/4ca5c11b2b5a6620154b7848c357e5b3fe489a32-2080x1296.png)

You will be asked to provide some basic details for your role.

![Shows dialog for creating new role](https://cdn.sanity.io/images/3do82whm/next/787e50a9c2ed123cabbc98ed5d57ef0feedb799f-648x516.png)

Once created you'll have the option of adding members to the role or proceeding to define permissions and restrictions. These are divided into two main categories: **Content Permissions** and **Management Permissions**.

### About viewer roles

Users with the "Viewer" role are free and don't count toward a plan's available seats. If a user with the viewer role is assigned an additional role, that user will count as a billable user. 

> [!WARNING]
> Gotcha
> Only the built-in viewer role is considered a "free viewer." Any custom role, even if it only grants read-only access, is billed as a regular user.

## Management Permissions

These settings grant a role access to your project's settings which are typically accessed in the project management console at [sanity.io/manage](https://sanity.io/manage). Access to a project's details and usage statistics, members and roles, API settings, and datasets and tags are currently available for configuration.



> [!WARNING]
> Gotcha
> In order for a role to have access to the project, **Project Details** should be set to **read**. For content editor roles, also setting **Project Members** to **read** will ensure they get the best studio experience with the full advantage of [Presence](https://www.sanity.io/blog/introducing-presence) features.
> Custom roles that work with datasets also need **Project datasets** set to **Read**, in the **Datasets and tags** section of **Management Permissions**. Every built-in role that grants project access includes this permission, so it's easy to miss when you build a custom role from scratch. Without it, requests that read the project's datasets fail with `Unauthorized - User is missing required grant sanity.project.datasets/read to perform this operation`.

## Content permissions

This is where you define the role's access to your Content Lake. You can grant any role wide-reaching privileges that extend to all your datasets or use GROQ filters to set up granular access to only certain content types.

Once you've navigated to the role you want to configure you'll be presented with a list of your datasets that can be individually configured, as well as the opportunity to set some base permissions for *all datasets*.

![Shows overview of permissions for role](https://cdn.sanity.io/images/3do82whm/next/eb9eecebbfb649ca75926f9c4e50e4d0cbcd1eef-2080x1560.png)

By default, all permissions are set to **No access**. Permissions cascade down from more general contexts to more specific ones, so it's generally better to start restrictive and grant privileges on each dataset separately as any permission granted on **All datasets** will override more restrictive settings in the individual datasets.

![Shows default permissions for all datasets set to "No access"](https://cdn.sanity.io/images/3do82whm/next/8d2232e47247037206b615fbac27a3b0bbebcc2a-677x313.png)

> [!WARNING]
> Gotcha
> Permissions are additive in nature. 
> That means you cannot remove a permission that has been granted elsewhere, either by another content resource on the same role or by another role that the same member holds.
> **Example**: If you defined that a role has `publish` rights for all documents in all datasets, it is impossible to define a resource (via GROQ filter) which only grants `read` access to a specific subset of documents.
> **Example**: A member who holds a custom role restricted to the `movie` type and also holds the stock Editor role can still edit every document, because Editor grants access to all documents and the two roles combine.

This hereditary characteristic of permissions is visualized when you go to edit the permission for a single dataset. The dialog shown below demonstrates how the final permissions for the dataset are derived from both the privileges set generally for all datasets and from the privileges set specifically for this dataset.

![Shows permissions on several levels of specificity](https://cdn.sanity.io/images/3do82whm/next/7e324ed31d419d7136c61cdbe4e9b0830d77db16-826x735.png)

The base set of content resources available for access control are general in nature but powerful enough to cover many use-cases. You may grant privileges to read, create and update, and publish each of the widely encompassing options; **All documents**, **Image assets**, and **File assets**.

> [!WARNING]
> Gotcha
> If your dataset is **public** all project members will have read access to your **published content** *even if their role is set to no access*!

## Content resources

**This is a paid feature**
This feature is available on certain Enterprise plans. [Talk to sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.

In addition to the basic set of permission scopes that lets you configure access to **All documents**, **Image assets**, and **File assets,** you may also create custom content resources to control access to particular content types, which you may then control the access to with your custom roles. To create a new content resource, select **Access › Resources** in your project's sidebar.

> [!WARNING]
> A content resource does not restrict a role held alongside it
> A content resource narrows only the grants of the role it belongs to. Because permissions are additive, a member or token that also holds a broader role, such as the stock Viewer or Editor, keeps that role's access and the restrictive filter has no effect.
> This matters most for a token you ship to the browser. Before you rely on a content resource to keep a token away from your drafts, confirm in the management console that the token holds only the custom role. See [Keeping your data safe](https://www.sanity.io/docs/content-lake/keeping-your-data-safe).

> [!WARNING]
> Gotcha
> The filter does not support dereferencing! This will **not** work: `referenceField->`! Instead, check against the `_ref` property when creating custom resources: `referenceField._ref == "my-referenced-doc-id"`.

![Shows the button described below](https://cdn.sanity.io/images/3do82whm/next/4f324593a0373521a2486807bf47e5afa91483b6-683x470.png)

In our example, we'll be working with the default starter template called *Movie Project*. This gives us a prefilled dataset with content types like `movie`, `person`, and *screening*. Click the button in the top right of the section to create a new content resource.

Name the content resource “Movies” and select the `movie` document type.

![A "Create new content resource" dialog showing "Document types" step with "Movie" selected.](https://cdn.sanity.io/images/3do82whm/next/b48cc6f3767b8b707c5a4c79888d5f907d3caae2-964x326.png)

No additional filter conditions are needed right now. Content resources leverage the power of [GROQ](https://www.sanity.io/docs/content-lake/how-queries-work) to filter which content types are affected by the privileges you choose to grant. In this example, we're using a simple but powerful GROQ expression to return only documents of type movie.

![A "Create new content resource" screen showing the active "Filter" step with a GROQ query `_type == "movie"`.](https://cdn.sanity.io/images/3do82whm/next/c928d4ad5e3a6e169c3b2f2e7630dbacafb74fdc-966x327.png)

> [!TIP]
> Protip
> With a deployed studio, the visual builder lets you build conditions using your schema definitions.

If we’re working with a studio without a deployed schema, we’ll need to provide the GROQ filter manually.

![Content resource creation screen showing the 'Filter' step with `_type == "movie"`.](https://cdn.sanity.io/images/3do82whm/next/e76344c99672de90f9783629033acc455fa1493a-998x331.png)

Once you hit save, you should see your new content resource added to the list.

![Shows the new content resource in the list of resource definitions](https://cdn.sanity.io/images/3do82whm/next/0707eba5207d5c510e085be4984a7dd6111e73cb-1766x576.png)

Back in **Access › Roles**, we can now set `movie`-specific privileges on our custom role.

![Shows dialog for specifying permissions on content resource](https://cdn.sanity.io/images/3do82whm/next/926857104bb65dbd336033dadbaf08ef442f51f2-649x728.png)

To test your role, make sure you have actually set the role on a member account and then proceed to log into the studio with the account in question.

![Shows list of project members, one with new role specified](https://cdn.sanity.io/images/3do82whm/next/dd43be021181aa698dcc53d7ed502f17534dd9ee-667x189.png)

Your account should be able to view, create, update and publish any document of the `movie` type, but should be unable to edit documents of any other type.

![Shows a notification stating that current user does not have permissions to update document](https://cdn.sanity.io/images/3do82whm/next/850bcc2e259960e29685bd8742b20f7aab09eaae-640x357.png)

![Shows a notification stating that current user does not have permissions to update document](https://cdn.sanity.io/images/3do82whm/next/2cdd6060d774cc8654bd556f6ef2c79eab88d276-641x287.png)

## Permissions for Studio features

Some features, like drafts, Content Releases, and Scheduled Drafts read and write system documents that don't come from your schema. Those documents are addressed by a fixed document-ID path rather than by a type: drafts are stored as `drafts.<documentId>`, the copy of a document inside a release is stored as `versions.<releaseId>.<documentId>`, and the release itself is stored as `_.releases.<releaseId>`. A custom role reaches them through content resources that filter on the document ID, so there is no schema type or `sanity.*` wildcard to grant instead. A role scoped only to your own document types blocks these features, even when the same user can edit the underlying content.

Create one content resource per filter, then set the role's access level for that resource. Access levels are cumulative: **Read** grants read only, **Update and create** adds creating and editing, and **Publish** adds deleting and publishing.

The following table lists the content resource each feature needs:

##### Permissions for Studio features

| Studio feature | Content resource filter | Access level |
| --- | --- | --- |
| Work with drafts | `_id in path("drafts.**")` | Update and create |
| Edit documents inside a content release | `_id in path("versions.**")` | Update and create |
| Create, schedule, publish, and archive releases | `_id in path("_.releases.**")` | Publish |
| Upload images and files | The built-in image assets and file assets resources | Update and create |

### Grant Schedule drafts on a single document

[Scheduled Drafts](https://www.sanity.io/docs/studio/scheduled-drafts) on a single document is a scheduled release with one document in it. The Studio creates a release document, copies the draft into the release as a version document, then schedules the release. A role that can use it therefore needs all three of the document-path resources in the table, not just the one for the document being scheduled.

### Troubleshoot a role that can't schedule or publish

- `_id in path("_.draft.**")` matches no documents: drafts use the `drafts.` prefix, so the filter you want is `_id in path("drafts.**")`.
- Scheduling fails with `Insufficient permissions; permission "update" required`: the `_.releases.**` resource is set below **Publish**. Scheduling a release is a deferred publish, so **Update and create** isn't enough on its own.

The Access API expresses release permissions at a finer grain. Each release action is gated by a synthetic document ID of the form `_.releases.<releaseId>.actions.<action>`, so a filter such as `_.releases.*.actions.schedule` authorizes one action at a time and lets you allow scheduling while withholding publishing. The `_id in path("_.releases.**")` filter matches the release document and every one of its action IDs at once, which is why it takes a single access level. To grant release actions individually, see [Build a custom role with the Access API](https://www.sanity.io/docs/content-lake/build-a-custom-role-with-the-access-api).

## User attributes

**This is a paid feature**
This feature is available on certain Enterprise plans. [Talk to sales](https://www.sanity.io/contact/sales?ref=docs) to learn more.

[User attributes](https://www.sanity.io/docs/http-reference/user-attributes) are key-value pairs that describe a user within your organization; things like `location="torrevieja"`, `department="front_desk"`, or `year_joined=2019`. You can reference these attributes in content resource filters to create **parameterized roles** that adapt to each user automatically, rather than creating separate roles for every location, department, or team.

### Where attributes come from

Attributes can come from two sources:

- **SAML**: When users authenticate via SSO (Okta, Azure AD, Auth0, etc.), Sanity automatically captures all attributes included in the SAML assertion. These are refreshed on every login. No pre-configuration is needed. If the identity provider sends it, Sanity stores it.
- **Sanity (manual)**: Administrators can define additional attributes and set values directly in Manage or through the API. You can also use manual attributes to override a SAML-provided value for a specific user.

When both SAML and Sanity provide a value for the same attribute key, the Sanity value takes precedence. Removing the Sanity override reveals the SAML value again.

### Defining attributes

To define and manage attributes, select **Members › Attributes** in your organization's sidebar. Any attributes that have been captured from SAML logins will appear here automatically.

> [!WARNING]
> Gotcha
> User attributes are defined and managed at the organization level, not on individual projects. If you don't see the **Attributes** section, make sure you're viewing your organization's settings in Manage rather than a project's settings.

To create a new attribute, click the button in the upper right corner. You'll be asked to provide an attribute key (the name) and a type. Supported types are `string`, `integer`, `number`, `boolean`, and array variants of each (except boolean).

![The organization's Members › Attributes page in Manage, showing a table of custom member attributes such as "brand," "contractor," and "department," with a search bar and a "Create attribute" button.](https://cdn.sanity.io/images/3do82whm/next/5b867db226f3f2c61f08def9da6dec7607065fe6-2080x1040.png)

> [!WARNING]
> Gotcha
> You cannot create a Sanity attribute definition when a SAML definition already exists for that key. If your identity provider sends an attribute like `location`, it will appear automatically—you don't need to define it again. You can set Sanity override values directly on individual users.

### Setting attribute values on users

To view and manage a user's attributes, navigate to that user within the Members section. You'll see all of their current attribute values, including which source each value comes from (SAML or Sanity) and which value is currently active.

From here you can:

- **Set a Sanity value** to override a SAML-provided attribute for this user.
- **Add a value** for an attribute that the user doesn't have from SAML.
- **Remove a Sanity override** to revert to the SAML value.

![A user role inspection panel, showing editable attributes and an 'Add attribute' dropdown open with options like 'brand' and 'department'.](https://cdn.sanity.io/images/3do82whm/next/0c2af3fa87daf1e83ef00f0eaa03259be0a53e26-708x842.png)

> [!TIP]
> Protip
> Overriding attributes is useful for temporary changes, like reassigning a user to a different location for a project, without modifying your identity provider. When you're done, remove the override and the SAML value takes effect again on the user's next login.

### Using attributes in content resources

Attributes become powerful when referenced in GROQ filters for content resources. Instead of hardcoding a value like `_type == "post" && branch == "london"`, you can use the `user::attributes()` function to make the filter dynamic:

```groq
_type == "post" && branch == user::attributes().branch
```

When a user with `branch="london"` accesses content through a role using this resource, the filter resolves to `branch == "london"`. A user with `branch="tokyo"` sees only Tokyo content. One role covers both users.

To create a parameterized content resource, follow the same steps as creating any content resource, but reference `user::attributes()` in your GROQ filter. The visual builder gives you an option to add user attribute conditions directly from your schema.

!["Create new content resource" screen with the "Filter" step selected, showing a GROQ filter configured to match documents where the user's "brand" attribute equals "brand".](https://cdn.sanity.io/images/3do82whm/next/c325a3c7921339ff819aa94d901c0d85dc3cd862-966x470.png)

#### Filter best practices

When you use `user::attributes()` in a content resource filter, be aware that if the referenced attribute is missing (due to a typo, a migration, or the user simply not having that attribute set), the expression evaluates to `null`. If the document field you're comparing against is also missing, the filter could simplify to `null == null`, which evaluates to `true`, granting access to all matching documents.

This means a misconfigured filter can silently escalate privileges rather than deny access.

To prevent this, either wrap `user::attributes()` in a `coalesce()` function or add an explicit `!= null` check:

**filters.groq**

```groq
// Unsafe: fails open when both sides are null
_type == "hotel" && location == user::attributes().userLocation

// Safe with coalesce: fails closed when the attribute is missing
_type == "hotel" && coalesce(user::attributes().userLocation, "__no_value__") == location

// Safe with null check: fails closed when the attribute is missing
_type == "hotel" && user::attributes().userLocation != null && location == user::attributes().userLocation
```

**Common scenarios where this matters:**

- The attribute name in the filter has a typo. For example, `userLocaton` instead of `userLocation`.
- An attribute migration removes or renames the attribute, but existing filters still reference the old name.
- A user has not been assigned the attribute referenced in the filter.
- The document field being compared against does not exist on some documents.

If any of these occur without a null guard, the filter will match all documents of the specified type, effectively disabling the access restriction.

### Example: genre-based editing

Continuing with our *Movie Project* example, imagine your team has editors who specialize in different genres. One group handles horror films, another covers documentaries, and so on. Without user attributes, you'd need a separate role for each genre: "Editor - Horror", "Editor - Documentary", "Editor - Comedy", and so on.

With user attributes, you define a single content resource with the filter:

```groq
_type == "movie" && genre == user::attributes().genre
```

Then create one role, such as "Genre Editor", that uses this content resource with read, create, update, and publish permissions. Assign the role to all editors. Each editor sees only the movies matching their genre, based on the `genre` attribute from their identity provider or set in Manage.

If an editor needs to temporarily cover a different genre, an administrator can set a Sanity override for that user's `genre` attribute without changing anything in the identity provider.

### Gotcha: SAML attribute types are inferred, and they can change to a list

SAML has no single-value type. Your identity provider always sends a claim as a list of values. Sanity infers the type of the attribute from the number of values that it receives:

- One value becomes a single value.
- Two or more values become a list.

A single value is not a final type. It only shows that no member of your organization has sent two values for that claim yet. When the first member signs in with two values, Sanity changes the attribute to a list for all members that hold that attribute. The stored values stay the same, but they become one-item lists.

Example, for a `department` claim:

```batchfile
Before   Alice   department = "Engineering"
         Bob     department = "Support"

Carol signs in, and her identity provider sends two departments.

After    Alice   department = ["Engineering"]
         Bob     department = ["Support"]
         Carol   department = ["Sales", "Ops"]
```

Alice and Bob did not sign in, and their department did not change. Only the shape of the value changed.

**Effect on filters:** Write your filters so that they work with a list. A filter that compares the attribute to an exact value stops to match after the change:

```typescript
// Fragile: fails after the attribute becomes a list.
user::attributes().department == "Engineering"

// Recommended: works for a single value and for a list.
user::attributes().department == "Engineering" || 
"Engineering" in user::attributes().department
```

Because a filter controls access, a filter that is not updated can deny access to members who had access before.

The change to a list is permanent for that attribute. A later sign-in with one value does not change the attribute back to a single value.

## Tags

Tags are a useful feature that lets you group datasets with similar characteristics together so that roles and permissions can be conveniently set on multiple datasets in a single operation. You might create tags for different environments, such as `production` and `staging`, or combine tags for different publications and locales, E.g. `elle` `us` or `vogue` `jp`.

**This is a paid feature**
This feature is available in the [Growth plan](https://www.sanity.io/pricing).

To create a new tag, select **Datasets › Tags** in your project's sidebar.

![Shows the button described above](https://cdn.sanity.io/images/3do82whm/next/55c7e0536da439f29d08e05ec42a4f53e1d56042-1540x466.png)

In the example shown below we'll be creating a tag for staging and production datasets for our movie blog, and then assigning editing privileges in both for our `movie-critic` role.

![Shows setup dialog for new dataset tag](https://cdn.sanity.io/images/3do82whm/next/80c860d3162574acd4c512d218d997eefdafec7b-645x639.png)

Once created, we can add datasets to the tag and define permissions to content resources for our custom roles.

![Shows content permissions for dataset tag](https://cdn.sanity.io/images/3do82whm/next/17b14929387db6f537a1b496a87029d25490f0f7-1546x988.png)

The change is reflected and can be edited in the content permissions for the custom role.

![Shows the permission setting as described above](https://cdn.sanity.io/images/3do82whm/next/552ddfb71be659d716500c07b44cfd7ed3d7e64f-1554x1280.png)





## Related changelog entries

Entries are listed newest first; each link points to the full entry as markdown. Follow one when you need to know what changed, when, or why — for example, to summarize recent updates, explain behavior that differs from older documentation, or check whether a fix has shipped.

- [Manage v2026-09-02: SAML attributes now become lists when your identity provider sends more than one value](https://www.sanity.io/docs/changelog/57d72a5e-2c62-4ec7-bac3-46e374f15170.md) — September 2, 2026
- [Manage v2026-09-01: Review your security posture in one place](https://www.sanity.io/docs/changelog/334072af-b48d-4e08-a5e2-2dfe3e177a8c.md) — September 1, 2026
- [Manage v2026-07-29: Define custom roles using a visual builder](https://www.sanity.io/docs/changelog/688e1170-eb14-4ff0-9e37-9b97abcfc044.md) — July 29, 2026
- [Sanity Studio v6.4.0: Improved requests error handling and bug fixes](https://www.sanity.io/docs/changelog/studio-Ni4zLjA.md) — July 7, 2026
- [Manage v2026-04-27: User Attributes for parameterized roles](https://www.sanity.io/docs/changelog/ee9fa5c1-2ea5-44ea-82c7-2b616fc26035.md) — May 8, 2026
- [Manage v2025-07-03: Create org-level tokens for SDK apps, Media Library, individual projects, and more](https://www.sanity.io/docs/changelog/0e67c42e-4b0f-47e3-bbd7-498318bdfb32.md) — July 3, 2025