Hasura – Authorization and Permissions

July 18, 20264 min readUpdated 8/21/2026

This is the most important lesson here, because a mistake in authentication is a bug and a mistake in authorization is a data breach. Hasura’s permission model is genuinely good, and it has three traps that catch people who assume it works like a framework they already know.

Permissions shape the schema

Start with the idea everything else follows from. Ask for bookings without a token:

{"errors":[{"message":"field 'bookings' not found in type: 'query_root'",
  "extensions":{"code":"validation-failed"}}]}

Not “denied”. Not empty. The field does not exist. Hasura builds a separate schema per role, so a role with no permission on a table has no such table — introspection shows them a smaller API. Access is not filtered out of a shared schema; the schema itself is per-role.

The four parts of a permission

A select permission answers four questions: which role, which rows, which columns, and how many at once.

def _select(columns: list[str], filter_: dict, *, limit: int | None = None) -> dict:
    perm = {"columns": columns, "filter": filter_, "allow_aggregations": True}
    if limit is not None:
        perm["limit"] = limit
    return perm

Columns are an allowlist

Anything you list is readable by that role. Anything you omit is invisible. This makes the column list the single most dangerous line in a metadata file:

USER_PUBLIC_COLUMNS = [
    "public_id", "first_name", "last_name", "avatar_url", "host_bio", "is_host", "created_at",
]
USER_SELF_COLUMNS = USER_PUBLIC_COLUMNS + ["email", "role"]

password_hash is not there, and never can be. Adding a column to that list publishes it. There is no second check.

The same reasoning applies to less obvious fields. The demo app deliberately excludes address_line1 and postal_code from the public listing columns — a booking site reveals the exact address only after booking, and publishing it tells the world which houses are empty next week.

Rows are a boolean expression

The filter is the same expression language as where, evaluated per row:

PUBLISHED_ONLY = {"_and": [{"status": {"_eq": "PUBLISHED"}}, {"deleted": {"_eq": False}}]}
NOTHING_HIDDEN = {}

An empty filter means every row.

Session variables make it per-user

Claims from the token are available inside the filter, which is how one rule serves every user:

OWN_PROPERTY = {"host": {"public_id": {"_eq": "X-Hasura-User-Id"}}}
OWN_BOOKING = {"guest": {"public_id": {"_eq": "X-Hasura-User-Id"}}}
BOOKING_AT_OWN_PROPERTY = {"property": OWN_PROPERTY}

Notice these walk relationships. “The booking whose property’s host is me” is expressible without duplicating host_id onto every child table — the filter traverses, so the schema stays normalised.

The limit is a safety valve

Without a row limit, query { properties } with no arguments returns the entire table. That is the classic way a GraphQL endpoint becomes a denial-of-service vector against its own database. Set one on every public role.

Three traps

1. admin is reserved

You cannot declare permissions for it. Hasura rejects the attempt with cannot define permission for admin role. Name your staff role something else — the demo app uses staff — or you will create a role whose rules can never exist.

2. Roles do not inherit

A host does not get customer’s permissions. Hasura roles are flat. Every role declares its rules in full, which is verbose and is also the point: a permission can never be granted by accident through a hierarchy somebody edited elsewhere.

3. Permissions do not cascade through relationships

Being allowed to read properties does not grant the related host row. Each table is evaluated independently. This is why a nested field silently disappears for one role — and the fix is a permission on the child table, not on the relationship.

The soft-delete trap

Worth its own section because it shipped a real bug. “Staff see everything” sounds like an empty filter. On a soft-deleting table it is not:

NOT_DELETED = {"deleted": {"_eq": False}}

A deleted row is gone as far as the product is concerned, and {} shows it. In the demo app the admin console counted listings that had been removed while the REST endpoint, which filters deleted = false in SQL, did not. Two totals for the same number, both plausible, one wrong.

This is the Hasura shape of a classic problem: the ORM applies the flag automatically, and anything written by hand — SQL or a permission rule — has to say so itself.

Insert, update and delete

Write permissions add two ideas. A check validates rows being written (as opposed to the filter, which selects rows being read), and preset values force a column from a session variable:

{
  "role": "customer",
  "permission": {
    "columns": ["property_id", "check_in", "check_out", "guests"],
    "check": {"guest": {"public_id": {"_eq": "X-Hasura-User-Id"}}},
    "set": {"guest_id": "x-hasura-user-id"}
  }
}

Presets are the important half. Leave guest_id in the client’s hands and somebody books on another person’s behalf; preset it from the token and they cannot, because the value never comes from the request.

Authorization in v3 (DDN)

On the v3 sections. Everything marked v3 (DDN) is taken from the official Hasura DDN documentation as read on 2026-08-21 and was not run locally — it shows configuration, never claimed output. The v2 material was executed against a running engine.

v3 splits one v2 permission into two objects, by what is being protected. TypePermissions covers fields:

kind: TypePermissions
version: v1
definition:
  typeName: Users
  permissions:
    - role: anonymous
      output:
        allowedFields: [publicId, firstName, avatarUrl, isHost]

And ModelPermissions covers rows:

kind: ModelPermissions
version: v1
definition:
  modelName: Properties
  permissions:
    - role: anonymous
      select:
        filter:
          fieldComparison:
            field: status
            operator: _eq
            value:
              literal: PUBLISHED

Commands get CommandPermissions. The concepts survive intact — roles, row filters, field allowlists, session variables — and the mechanics change completely. One further difference: permissions are declared per subgraph, so each team owns the rules for its own domain rather than everything living in a single document.

Next

Lesson 10 covers what to do when a request needs to do something Hasura cannot generate.