Claude Code with Hasura: Instant GraphQL Without the Pitfalls
Why Hasura without CLAUDE.md ships an open database
Hasura turns a Postgres database into an instant GraphQL API. You track a table, the API gains queries, mutations, and subscriptions for that table, and a client app can read and write without any backend code in between. The speed is the appeal. The risk is that Claude Code does not know the difference between "the API works" and "the API is safe to expose to the public internet". Without explicit constraints, Claude tracks a table, adds a permission for the anonymous role that returns every column, and ships an endpoint that lets any visitor read or update any row.
The most common Claude defaults that break a Hasura project: tracking tables without setting role-based permissions, granting select to anonymous with no row filter, adding columns to a table without updating the permission column list, defining relationships in Hasura that the underlying Postgres schema does not enforce, writing event triggers without idempotency keys, and pushing metadata changes to production without running the migration that backs them. On top of those, Claude makes a consistent CLI-level mistake: it edits metadata/databases/default/tables/public_users.yaml by hand without running hasura metadata reload, and then wonders why the change does not appear in the console.
This guide covers the CLAUDE.md configuration that locks Claude Code into Hasura's safe defaults: a user role with explicit per-table permissions, every permission scoped by a session variable, every event trigger idempotent on a unique key, and the metadata and migration files synchronised in every commit. For the Postgres schema layer, Claude Code with Drizzle covers the migration pattern that pairs cleanly with Hasura's tracked tables. For the backend that hosts your action handlers, Claude Code with Express shows the request lifecycle Hasura webhooks plug into.
The Hasura CLAUDE.md template
The CLAUDE.md at your project root is read at the start of every Claude Code session. For a Hasura integration it needs to declare: the CLI version, the project directory layout, the role taxonomy, the permission rules, the relationship policy, the event trigger contract, the action handler shape, and the hard rules that block the mistakes Claude makes most often.
# Hasura rules
## Stack
- hasura-cli ^2.x
- Hasura GraphQL Engine ^2.36 (on Hasura Cloud or self-hosted)
- Postgres 15+ as the primary database (no implicit MySQL)
- HASURA_GRAPHQL_ADMIN_SECRET in .env.local (never hardcode)
## Project structure
- hasura/config.yaml , project config
- hasura/metadata/ , metadata YAML files (version-controlled)
- hasura/migrations/ , SQL migrations (one folder per change)
- hasura/seeds/ , seed data SQL files
## Role taxonomy (MANDATORY)
- admin: superuser, set in admin secret header (no row permissions, has all)
- user: authenticated user, scoped by session var x-hasura-user-id
- public: unauthenticated read-only access to truly public data only
- NEVER use the role 'anonymous' with anything beyond a hardcoded health-check table
## Permission rules (every tracked table MUST declare these)
For each role, declare select / insert / update / delete with:
- Columns: explicit allowlist, NEVER ['*'] without review
- Row filter: scoped by session variable
- User role MUST filter: { user_id: { _eq: 'X-Hasura-User-Id' } }
- Public role MUST filter: { is_public: { _eq: true } } or similar
- Limit: select permissions MUST set a limit (typical 50-200) to prevent unbounded queries
- Backend-only: insert/update for system fields (created_at, updated_at) MUST use column presets
## Migration and metadata pairing
- Every schema change is a pair: SQL migration + metadata update
- ALWAYS run: hasura migrate create <name> --from-server
- ALWAYS run: hasura metadata export, then commit the diff
- NEVER edit metadata YAML by hand AND push to live, without hasura metadata apply
- NEVER hand-edit a migration file after it has been applied to staging or production
## Event triggers (MANDATORY shape)
- payload: { event: { op, data: { old, new } }, table, trigger, id }
- Handler MUST be idempotent on event.id (Hasura retries on non-2xx)
- Handler MUST return 200 within 30s or Hasura retries
- Handler MUST verify the trigger secret header (HASURA_EVENT_SECRET)
- Long jobs: enqueue and return 200, do NOT process inline
## Action handler shape
- Hasura calls your handler with: { action: { name }, input, session_variables }
- Handler returns the action's output type as JSON
- Handler MUST verify HASURA_ACTION_SECRET header
- Errors: throw with { message: '...' } and Hasura surfaces the message to the client
## Hard rules
- NEVER track a table without immediately adding permissions for user and public roles
- NEVER grant select to public without a row filter
- NEVER use ['*'] for permission columns without explicit comment justifying it
- NEVER apply metadata without an accompanying migration
- NEVER expose admin secret to the client
- NEVER define a Hasura relationship without the underlying foreign key in Postgres
- ALWAYS run hasura migrate apply before hasura metadata apply
Four rules here prevent the majority of broken Hasura projects Claude generates without them.
The role taxonomy rule removes the ambiguity that leads to data leaks. Claude defaults to whatever role string appears in the example it last saw, which is usually anonymous. That role exists in the Hasura quickstart, gets permissive defaults by accident, and then ships to production. Declaring user and public as the only application roles, and reserving anonymous for a narrow health-check use case, makes every permission decision explicit.
The row filter rule is the single most important security primitive in Hasura. Permissions in Hasura are per-role, per-operation, and per-row. The per-row scoping uses session variables that the JWT middleware injects. The filter { user_id: { _eq: 'X-Hasura-User-Id' } } ensures that a user role can only read or modify rows whose user_id column matches the authenticated session. Claude defaults to omitting the row filter because the permission still works without it. Without the filter, every authenticated user can read every row.
The migration and metadata pairing rule prevents the most common drift between environments. A Hasura project has two sources of truth: the SQL migrations that define the database schema and the metadata YAML files that define the tracking, permissions, relationships, and triggers. Applying one without the other leaves the system in a half-configured state. Claude often edits metadata YAML by hand and forgets to generate the SQL migration that makes the same change in Postgres. The CLAUDE.md rule forces both to move together.
The event trigger idempotency rule prevents duplicate side effects. Hasura retries event triggers when the handler returns a non-2xx response or times out. The retry uses the same event.id, but if your handler is not idempotent, you send the welcome email three times. Declaring idempotency as a hard rule, with event.id as the dedup key, makes Claude generate the right pattern.
Install and project setup
Install the Hasura CLI:
npm i -g hasura-cli
Initialise the project:
hasura init hasura --endpoint https://your-project.hasura.app
cd hasura
The init command creates the directory layout the CLAUDE.md rule expects. Add the admin secret to your environment:
# .env.local
HASURA_GRAPHQL_ADMIN_SECRET=your_admin_secret
HASURA_GRAPHQL_ENDPOINT=https://your-project.hasura.app
HASURA_EVENT_SECRET=long_random_string_for_event_triggers
HASURA_ACTION_SECRET=long_random_string_for_actions
Update hasura/config.yaml to read from environment variables:
version: 3
endpoint: https://your-project.hasura.app
admin_secret: HASURA_GRAPHQL_ADMIN_SECRET
metadata_directory: metadata
migrations_directory: migrations
seeds_directory: seeds
The admin secret value in config.yaml is a reference to an environment variable name, not the secret itself. Claude sometimes copies the literal secret value into config.yaml because it works locally. Declare the env-var reference pattern in CLAUDE.md and Claude will follow it.
The permission model
Permissions are the security boundary of a Hasura project. Every tracked table needs a per-role permission declaration. The default state for a freshly tracked table is no permissions for any non-admin role, which means the API exists but cannot be called by users. That default is correct. The mistake is granting select to the user role with no filter.
A correct user-role select permission, in metadata/databases/default/tables/public_posts.yaml:
table:
schema: public
name: posts
select_permissions:
- role: user
permission:
columns:
- id
- title
- body
- author_id
- created_at
- updated_at
filter:
author_id:
_eq: X-Hasura-User-Id
limit: 50
allow_aggregations: false
Three things to note. The columns list is explicit, not *. The filter scopes by the session variable, not all rows. The limit caps the result set so a client cannot request unbounded data. These three are what the CLAUDE.md permission rule encodes.
A correct insert permission with column preset:
insert_permissions:
- role: user
permission:
columns:
- title
- body
set:
author_id: X-Hasura-User-Id
check: {}
backend_only: false
The set field is a column preset, meaning Hasura injects author_id from the session at write time. The client cannot send author_id in the mutation, and even if they try, Hasura overrides it. This is the right pattern for any system field that must come from the session, not the request.
Add a concrete permission example to CLAUDE.md, not just rules:
## Permission example (CONCRETE - copy this shape)
For any user-owned table (posts, notes, projects, anything with an owner):
select_permissions:
- role: user
permission:
columns: [<explicit list, no '*'>]
filter:
<owner_column>:
_eq: X-Hasura-User-Id
limit: 50
insert_permissions:
- role: user
permission:
columns: [<client-writable list, NEVER include owner_column>]
set:
<owner_column>: X-Hasura-User-Id
check: {}
update_permissions:
- role: user
permission:
columns: [<client-writable list>]
filter:
<owner_column>:
_eq: X-Hasura-User-Id
check:
<owner_column>:
_eq: X-Hasura-User-Id
delete_permissions:
- role: user
permission:
filter:
<owner_column>:
_eq: X-Hasura-User-Id
The check field on insert and update is a post-condition. Hasura verifies that after the operation the row still matches the check. For insert it prevents the client from setting author_id to a different user. For update it prevents an update that would change the owner to escape the user's scope.
Migrations and metadata pairing
A Hasura schema change requires two artefacts: the SQL migration that changes Postgres, and the metadata change that tells Hasura how to expose the new shape. They must move together.
Adding a column to an existing tracked table:
# 1. Create a migration
hasura migrate create add_status_to_posts --database-name default
# 2. Write the SQL in the up.sql file
echo "ALTER TABLE public.posts ADD COLUMN status TEXT NOT NULL DEFAULT 'draft';" > \
hasura/migrations/default/<timestamp>_add_status_to_posts/up.sql
echo "ALTER TABLE public.posts DROP COLUMN status;" > \
hasura/migrations/default/<timestamp>_add_status_to_posts/down.sql
# 3. Apply the migration
hasura migrate apply --database-name default
# 4. Export the updated metadata (Hasura discovers the new column)
hasura metadata export
# 5. Manually add the column to the relevant permission column lists in the YAML
# (Hasura does not auto-add to permissions - that is intentional, columns are opt-in)
# 6. Re-apply metadata so the permission update lands on the server
hasura metadata apply
Step 5 is the one Claude skips. It applies the migration, the metadata export picks up the new column, but it does not appear in any role's permission list. The role cannot read the new column. The client query fails with a confusing field-not-found error. The CLAUDE.md rule "any schema change is paired migration + metadata update" makes step 5 the explicit thing Claude must remember.
Add a concrete workflow checklist to CLAUDE.md:
## Schema change checklist (CONCRETE - every time)
1. hasura migrate create <name> --database-name default
2. Write up.sql and down.sql
3. hasura migrate apply
4. hasura metadata export
5. Manually update permission column lists for the changed table
6. hasura metadata apply
7. Commit both the migration folder AND the metadata YAML changes in one commit
Skip any of these and the environments drift.
Relationships
Hasura's relationships let queries traverse foreign keys. A relationship is a metadata-level concept that maps to an underlying Postgres foreign key. The mistake Claude makes is defining a Hasura relationship without the foreign key, which makes the relationship work in development against a small dataset and silently corrupt at scale because there is no referential integrity.
Declaring the foreign key in the migration:
-- migrations/default/<timestamp>_create_comments/up.sql
CREATE TABLE public.comments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
post_id UUID NOT NULL REFERENCES public.posts(id) ON DELETE CASCADE,
author_id UUID NOT NULL REFERENCES public.users(id),
body TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX comments_post_id_idx ON public.comments(post_id);
CREATE INDEX comments_author_id_idx ON public.comments(author_id);
Then the metadata picks up the relationships:
# metadata/databases/default/tables/public_posts.yaml
table:
schema: public
name: posts
array_relationships:
- name: comments
using:
foreign_key_constraint_on:
column: post_id
table:
schema: public
name: comments
The foreign_key_constraint_on form is the right one. It binds the relationship to the actual Postgres foreign key, so deletes cascade as the SQL declares, the foreign key is indexed for performance, and Hasura's planner can use the constraint for joins.
Add a relationship rule to CLAUDE.md:
## Relationships
- Every Hasura relationship MUST be backed by a Postgres foreign key
- ALWAYS use foreign_key_constraint_on, NEVER manual_configuration unless joining across databases
- Foreign key columns MUST have an index (Hasura does not auto-create one)
- Array relationships need an index on the joining column
- Object relationships need the foreign key constraint on the source table
Event triggers
Event triggers fire a webhook when a row in a tracked table is inserted, updated, or deleted. They are how Hasura integrates with external services: send an email when a user signs up, update a search index when a post is published, invalidate a cache when an order ships.
The trigger metadata:
# metadata/databases/default/tables/public_orders.yaml
table:
schema: public
name: orders
event_triggers:
- name: order_placed
definition:
enable_manual: false
insert:
columns: '*'
retry_conf:
num_retries: 3
interval_sec: 10
timeout_sec: 30
webhook_from_env: ORDER_WEBHOOK_URL
headers:
- name: X-Hasura-Event-Secret
value_from_env: HASURA_EVENT_SECRET
The handler:
// src/app/api/webhooks/hasura/order-placed/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { sendOrderConfirmation } from '@/lib/email';
import { db } from '@/lib/db';
const EVENT_SECRET = process.env.HASURA_EVENT_SECRET;
interface HasuraEvent {
event: {
op: 'INSERT' | 'UPDATE' | 'DELETE';
data: {
old: Record<string, unknown> | null;
new: Record<string, unknown> | null;
};
};
id: string;
table: { schema: string; name: string };
trigger: { name: string };
}
export async function POST(req: NextRequest) {
// 1. Verify the secret header
const secret = req.headers.get('x-hasura-event-secret');
if (!EVENT_SECRET || secret !== EVENT_SECRET) {
return NextResponse.json({ error: 'Unauthorised' }, { status: 401 });
}
const payload = (await req.json()) as HasuraEvent;
// 2. Idempotency: check if this event was already processed
const existing = await db.processedEvent.findUnique({
where: { id: payload.id },
});
if (existing) {
return NextResponse.json({ ok: true, idempotent: true });
}
// 3. Process the event
const order = payload.event.data.new as { id: string; user_email: string };
await sendOrderConfirmation(order.id, order.user_email);
// 4. Mark as processed
await db.processedEvent.create({
data: { id: payload.id, type: 'order_placed' },
});
return NextResponse.json({ ok: true });
}
Three patterns the handler implements that Claude does not generate without CLAUDE.md rules: the secret header check, the idempotency lookup on payload.id, and the 200 OK return for the duplicate case. Returning a 4xx or 5xx for a duplicate causes Hasura to retry, which compounds the problem.
Add an event trigger handler template to CLAUDE.md:
## Event trigger handler template
Every handler MUST follow this shape:
1. Verify x-hasura-event-secret header against HASURA_EVENT_SECRET env var
2. Parse payload as HasuraEvent
3. Check if payload.id has been processed before (DB lookup or Redis SETNX)
4. If processed: return 200 with { ok: true, idempotent: true }
5. Process the event (idempotent operations preferred even after dedup check)
6. Record payload.id as processed
7. Return 200
NEVER return non-2xx for a duplicate - that triggers Hasura retry
NEVER do long synchronous work inside the handler - enqueue and return
ALWAYS handle the 30-second timeout: if you might exceed, enqueue immediately
Actions
Actions let you extend the GraphQL schema with custom resolvers that call your own webhooks. They are the right pattern when the operation does not map to a CRUD-style mutation on a table, things like third-party API calls, multi-step transactions, or operations that need application logic Postgres cannot express.
Defining an action in metadata/actions.yaml:
actions:
- name: stripeCheckout
definition:
kind: synchronous
handler: '{{STRIPE_CHECKOUT_HANDLER}}'
forward_client_headers: false
headers:
- name: X-Hasura-Action-Secret
value_from_env: HASURA_ACTION_SECRET
timeout: 30
type: mutation
permissions:
- role: user
custom_types:
enums: []
input_objects: []
objects:
- name: StripeCheckoutOutput
fields:
- name: url
type: String!
- name: session_id
type: String!
scalars: []
Defining the input and output types in metadata/actions.graphql:
type Mutation {
stripeCheckout(price_id: String!): StripeCheckoutOutput
}
type StripeCheckoutOutput {
url: String!
session_id: String!
}
The handler:
// src/app/api/hasura-actions/stripe-checkout/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
const ACTION_SECRET = process.env.HASURA_ACTION_SECRET;
interface ActionRequest {
action: { name: string };
input: { price_id: string };
session_variables: {
'x-hasura-user-id': string;
'x-hasura-role': string;
};
}
export async function POST(req: NextRequest) {
if (req.headers.get('x-hasura-action-secret') !== ACTION_SECRET) {
return NextResponse.json({ error: 'Unauthorised' }, { status: 401 });
}
const { input, session_variables } = (await req.json()) as ActionRequest;
const userId = session_variables['x-hasura-user-id'];
try {
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [{ price: input.price_id, quantity: 1 }],
success_url: `${process.env.SITE_URL}/success`,
cancel_url: `${process.env.SITE_URL}/cancel`,
metadata: { user_id: userId },
});
return NextResponse.json({
url: session.url,
session_id: session.id,
});
} catch (err) {
return NextResponse.json(
{ message: err instanceof Error ? err.message : 'Checkout failed' },
{ status: 400 },
);
}
}
The error path returns a JSON object with a message field. Hasura unwraps the message and surfaces it to the GraphQL client as the error message. Returning a generic error string loses the detail the client needs to display. The CLAUDE.md action template names the { message } shape.
For a worked Stripe action example, Claude Code with Stripe covers the checkout session pattern in more depth.
Common Claude Code mistakes with Hasura
Six patterns Claude generates incorrectly without CLAUDE.md constraints.
1. Tracking a table without permissions
Claude generates: hasura console track action, then no permission YAML for any role.
Correct pattern: every track action is followed immediately by adding user and public role permissions (even if public is select_permissions: []).
2. Permissive anonymous role
Claude generates: a select permission for anonymous with filter: {} (no row filter, all rows).
Correct pattern: anonymous only on tables that are fully public reference data. User-scoped data goes through the user role with a session variable filter.
3. Editing metadata without a migration
Claude generates: a metadata YAML change to add a column, no SQL migration.
Correct pattern: every schema change starts with hasura migrate create, then hasura migrate apply, then hasura metadata export, then permission updates, then hasura metadata apply.
4. Relationship without foreign key
Claude generates: a Hasura relationship using manual_configuration because the underlying foreign key was forgotten.
Correct pattern: the foreign key in the migration first, then the relationship via foreign_key_constraint_on.
5. Non-idempotent event trigger handler
Claude generates: a handler that calls sendEmail() directly on every invocation.
Correct pattern: lookup on event.id, process if not seen, return 200 for duplicates.
6. Hardcoded handler URL
Claude generates: webhook: 'https://my-app.vercel.app/api/webhooks/order'.
Correct pattern: webhook_from_env: ORDER_WEBHOOK_URL, with the URL set per environment in the Hasura dashboard.
Add this list to CLAUDE.md with the corrected form for each. Claude reproduces patterns it has seen recently.
Permission hooks for Hasura CLI
A Hasura project accumulates scripts and CLI calls: schema dumps, metadata reloads, migration applies, console launches. Some are read-only. Some change production schema. Permission hooks gate the destructive ones.
In .claude/settings.local.json:
{
"permissions": {
"allow": [
"Bash(hasura console*)",
"Bash(hasura metadata export*)",
"Bash(hasura migrate status*)",
"Bash(hasura migrate create*)"
],
"deny": [
"Bash(hasura migrate apply --envfile .env.production*)",
"Bash(hasura metadata apply --envfile .env.production*)",
"Bash(hasura migrate squash*)"
]
}
}
Console launches, metadata exports, and migration creation are local operations. Applying migrations or metadata to production needs explicit confirmation. Squashing migrations rewrites history and should always be a deliberate human action.
Building Hasura projects that ship without leaks
The Hasura CLAUDE.md in this guide produces GraphQL APIs where every tracked table has explicit per-role permissions, every user-scoped permission filters by X-Hasura-User-Id, schema changes pair migrations and metadata, relationships are backed by Postgres foreign keys, event triggers are idempotent on event.id, and action handlers verify their secret header.
The underlying principle is the same as any infrastructure tool with Claude Code. Hasura without a CLAUDE.md produces APIs that work in development and leak in production: anonymous roles that expose private rows, tables that lose their owner filter when a new column is added, event triggers that fire twice because the retry path was not considered, and migrations that drift between environments because metadata was hand-edited.
For deeper Postgres patterns that pair with Hasura, Claude Code with Drizzle covers the schema and migration discipline that keeps the Postgres side clean. Claudify includes a Hasura-specific CLAUDE.md template with the role taxonomy, permission filter examples, migration and metadata pairing checklist, event trigger handler template, action handler shape, and all six common-mistake rules pre-configured.
Get Claudify. Ship Hasura projects that are safe to expose on day one.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify