Understand and Secure Your Hasura APIs

Learn the 5-layer security model for Next.js API routes and Hasura Actions.

Introduction

30 Second Summary

When AI generates backend code for you, it's easy to trust it without understanding what's actually happening. But if you can't explain why your API is secure, you can't fix it when something breaks.

In this project, you will take apart a real API route layer by layer, rebuild the same operation as a Hasura Action, and walk away with a security checklist you can use to audit any code that Claude generates.

What You'll Build

A side-by-side comparison of the same backend operation ("Assign Tenant to Unit") built as both a Next.js API route and a Hasura Action, with full security annotations at every layer.

By the end of this project, you'll have:

  • A fully annotated Next.js API route with all 5 security layers explained.
  • The same operation wired as a Hasura Action so you can compare both approaches.
  • A reusable security audit checklist for checking any API that AI generates for you.
  • Secret Mission: Add a webhook secret header so only Hasura can call your Action handler.

Are there any prerequisites?

This project assumes you already have a self-hosted Docker Compose setup running locally with Hasura, PostgreSQL, and Nhost Auth.

You also need a Next.js App Router project connected to that stack and basic familiarity with TypeScript and GraphQL mutations.

Before We Start

Before We Start...

Build the 5-Layer Secure API Route

Every backend API your app exposes is a door. Without proper security, that door is wide open to anyone on the internet. In this step, you'll build a secure "Assign Tenant to Unit" API route from scratch, layering in 5 distinct security checks that protect your data at every level.

You'll create the authentication utilities, a server-side Hasura client, and a fully annotated API route. Then you'll test each layer by sending bad requests and watching them get rejected.

In this step, get ready to:
  • Create authentication utilities that verify JWTs and extract user context.
  • Build a server-side Hasura client that uses the admin secret safely.
  • Wire up a 5-layer secure API route and test each layer individually.
Map out the 5 security layers

Every secure API route follows the same 5-layer pattern. Each layer catches a different type of bad request before it can reach your database.

What are the 5 security layers?

  • Layer 1: Authentication — Verify the JWT token to prove WHO is calling.
  • Layer 2: Role Authorization — Check the user's role to prove they're ALLOWED to do this action.
  • Layer 3: Data Scoping — Verify the user can access THIS specific piece of data (not just any data).
  • Layer 4: Input Validation — Reject garbage input before it hits your database.
  • Layer 5: Business Logic — The actual work, wrapped in error handling.

You'll start by building the auth utilities that handle Layers 1 and 2.

  • In Cursor, create a new file at lib/auth.ts and paste the following code:
import jwt from "jsonwebtoken";

const JWT_SECRET = process.env.NHOST_JWT_SECRET
  ? JSON.parse(process.env.NHOST_JWT_SECRET).key
  : process.env.JWT_SECRET!;

interface DecodedToken {
  sub: string;
  "https://hasura.io/jwt/claims": {
    "x-hasura-allowed-roles": string[];
    "x-hasura-default-role": string;
    "x-hasura-user-id": string;
    "x-hasura-operator-id"?: string;
  };
  exp: number;
  iat: number;
}

interface UserContext {
  userId: string;
  role: string;
  allowedRoles: string[];
  operatorId?: string;
}

export function verifyJwt(token: string): DecodedToken | null {
  try {
    const decoded = jwt.verify(token, JWT_SECRET) as DecodedToken;
    return decoded;
  } catch {
    return null;
  }
}

export function getUserContext(decoded: DecodedToken): UserContext {
  const claims = decoded["https://hasura.io/jwt/claims"];
  return {
    userId: claims["x-hasura-user-id"],
    role: claims["x-hasura-default-role"],
    allowedRoles: claims["x-hasura-allowed-roles"],
    operatorId: claims["x-hasura-operator-id"],
  };
}

What does this code do?

  • JWT_SECRET reads your signing key from the environment. It checks NHOST_JWT_SECRET first (which stores it as JSON), then falls back to a plain JWT_SECRET variable.
  • DecodedToken defines the shape of the JWT payload. Hasura stores claims under the https://hasura.io/jwt/claims namespace, which includes the user ID, role, and allowed roles.
  • verifyJwt attempts to decode and verify a token. If the token is expired, tampered with, or signed by a different key, it returns null.
  • getUserContext extracts a clean object with the user's ID, default role, and allowed roles from the decoded token's Hasura claims.
  • Save lib/auth.ts.

Now you'll create the main API route file that uses these auth utilities alongside the other security layers.

  • Create a new file at app/api/tenants/assign-unit/route.ts and paste the imports, security checklist, and validation schema:
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { verifyJwt, getUserContext } from "@/lib/auth";
import { hasuraAdminClient } from "@/lib/hasura-client";

/**
 * SECURITY AUDIT CHECKLIST:
 * ✅ 1. JWT verified? — Yes (verifyJwt)
 * ✅ 2. Role checked? — Yes (operator_admin or property_manager)
 * ✅ 3. Data scoped? — Yes (verifyPropertyAccess)
 * ✅ 4. Input validated? — Yes (Zod schema)
 * ✅ 5. Errors handled? — Yes (try/catch with proper status codes)
 */

const bodySchema = z.object({
  tenant_id: z.string().uuid("tenant_id must be a valid UUID"),
  unit_id: z.string().uuid("unit_id must be a valid UUID"),
  property_id: z.string().uuid("property_id must be a valid UUID"),
  lease_start: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Must be YYYY-MM-DD"),
  lease_end: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Must be YYYY-MM-DD"),
  monthly_rent: z.number().positive("Rent must be positive"),
});

What does this code do?

  • The security audit checklist at the top is a comment block you can paste at the top of ANY API route to quickly check if all 5 layers are present.
  • bodySchema is Layer 4 (Input Validation). It uses Zod to define the exact shape of valid input. Every field must be a UUID, dates must match YYYY-MM-DD, and rent must be a positive number. Anything else gets rejected before touching your database.
  • Below the schema in the same file, paste the POST handler function:
export async function POST(request: NextRequest) {
  const token = request.headers.get("authorization")?.replace("Bearer ", "");
  if (!token) {
    return NextResponse.json({ error: "No token provided" }, { status: 401 });
  }

  const decoded = verifyJwt(token);
  if (!decoded) {
    return NextResponse.json({ error: "Invalid token" }, { status: 401 });
  }

  const ctx = getUserContext(decoded);
  const allowedRoles = ["operator_admin", "property_manager"];
  if (!allowedRoles.includes(ctx.role)) {
    return NextResponse.json({ error: "Forbidden" }, { status: 403 });
  }

  let input: z.infer<typeof bodySchema>;
  try {
    input = bodySchema.parse(await request.json());
  } catch (err) {
    if (err instanceof z.ZodError) {
      return NextResponse.json(
        { error: "Invalid input", issues: err.issues },
        { status: 400 }
      );
    }
    return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
  }

  const hasAccess = await verifyPropertyAccess(ctx.userId, input.property_id);
  if (!hasAccess) {
    return NextResponse.json(
      { error: "You don't have access to this property" },
      { status: 403 }
    );
  }

  try {
    const result = await assignTenantToUnit(input, ctx.userId);
    return NextResponse.json(result, { status: 200 });
  } catch (err) {
    const message = err instanceof Error ? err.message : "Unknown error";
    console.error("[assign-unit] failed:", message);
    return NextResponse.json({ error: message }, { status: 500 });
  }
}

How the 5 layers work together

Read the function from top to bottom. Each section is a gate that rejects bad requests before they reach the next layer.

  • Lines 1-8: Layer 1 (Authentication). Extract the Bearer token and verify it. No token or invalid token returns 401.
  • Lines 10-14: Layer 2 (Role check). Only operator_admin or property_manager roles can assign tenants. Wrong role returns 403.
  • Lines 16-25: Layer 4 (Input validation). Parse the request body against the Zod schema. Invalid data returns 400.
  • Lines 27-32: Layer 3 (Data scoping). Check that this specific user has access to this specific property. This prevents a user from one building touching another building's data.
  • Lines 34-41: Layer 5 (Business logic). The actual work, wrapped in a try/catch so errors return 500 with a useful message.
  • Save app/api/tenants/assign-unit/route.ts.
  • You'll see TypeScript errors for verifyPropertyAccess and assignTenantToUnit because those helper functions don't exist yet. You'll add them in the next substep.

Seeing import errors for @/lib/auth?

Make sure your lib/auth.ts file is saved in the lib folder at the root of your Next.js project (not inside app/lib). The @/ prefix maps to your project root, so @/lib/auth resolves to <project-root>/lib/auth.ts.

help me fix my import path for @/lib/auth

Write the Admin Secret service layer

The helper functions in your route need a way to talk to Hasura with full permissions. That's what the admin secret does. It gives your server-side code unrestricted access to Hasura's GraphQL API.

  • Create a new file at lib/hasura-client.ts and paste the following:
const HASURA_GRAPHQL_URL =
  process.env.HASURA_GRAPHQL_URL || "http://hasura:8080/v1/graphql";
const HASURA_ADMIN_SECRET = process.env.HASURA_GRAPHQL_ADMIN_SECRET!;

export const hasuraAdminClient = {
  async request(query: string, variables: Record<string, unknown> = {}) {
    const response = await fetch(HASURA_GRAPHQL_URL, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "x-hasura-admin-secret": HASURA_ADMIN_SECRET,
      },
      body: JSON.stringify({ query, variables }),
    });

    const json = await response.json();

    if (json.errors) {
      throw new Error(json.errors[0]?.message || "GraphQL error");
    }

    return json.data;
  },
};

Why is the admin secret safe here?

The admin secret bypasses ALL Hasura permission rules. That sounds scary, but it's safe because of three guarantees.

  • This file only runs on the server. Next.js never ships lib/ files to the browser.
  • The secret lives in an environment variable. It's never hardcoded in your source.
  • Your API route validates the caller FIRST (JWT + role + data scoping) before calling any function in this file.

This is the same pattern used everywhere in industry. Stripe uses a server-side secret key. AWS uses IAM credentials server-side. Firebase Admin SDK uses a service account key.

  • Save lib/hasura-client.ts.

Now you'll add the two helper functions to your route file that use the admin client.

  • Return to app/api/tenants/assign-unit/route.ts and paste the following helper functions below the closing } of the POST handler:
async function verifyPropertyAccess(
  userId: string,
  propertyId: string
): Promise<boolean> {
  const query = `
    query CheckAccess($userId: uuid!, $propertyId: uuid!) {
      property_members(
        where: {
          user_id: { _eq: $userId }
          property_id: { _eq: $propertyId }
        }
      ) {
        id
      }
    }
  `;
  const data = await hasuraAdminClient.request(query, { userId, propertyId });
  return data.property_members.length > 0;
}

async function assignTenantToUnit(
  input: z.infer<typeof bodySchema>,
  assignedBy: string
) {
  const mutation = `
    mutation AssignTenant(
      $tenantId: uuid!
      $unitId: uuid!
      $propertyId: uuid!
      $leaseStart: date!
      $leaseEnd: date!
      $monthlyRent: numeric!
      $assignedBy: uuid!
    ) {
      insert_leases_one(object: {
        tenant_id: $tenantId
        unit_id: $unitId
        property_id: $propertyId
        start_date: $leaseStart
        end_date: $leaseEnd
        monthly_rent: $monthlyRent
        status: "active"
        created_by: $assignedBy
      }) {
        id
        status
      }
      update_units_by_pk(
        pk_columns: { id: $unitId }
        _set: { status: "occupied", current_tenant_id: $tenantId }
      ) {
        id
        status
      }
    }
  `;
  return hasuraAdminClient.request(mutation, {
    tenantId: input.tenant_id,
    unitId: input.unit_id,
    propertyId: input.property_id,
    leaseStart: input.lease_start,
    leaseEnd: input.lease_end,
    monthlyRent: input.monthly_rent,
    assignedBy,
  });
}

What do these helpers do?

  • verifyPropertyAccess is Layer 3 (Data Scoping). It queries the property_members table to check if this user actually belongs to this property. A user from Building A cannot modify Building B's data, even if their role is correct.
  • assignTenantToUnit is Layer 5 (Business Logic). It performs TWO database operations atomically: creating a lease record AND updating the unit's status to "occupied". A single GraphQL mutation from the frontend can't do both atomically.
  • Save app/api/tenants/assign-unit/route.ts. The TypeScript errors from earlier should now be resolved.

Still seeing TypeScript errors?

Check that your hasuraAdminClient import points to @/lib/hasura-client (not @/lib/hasuraClient or similar). The filename uses a hyphen, not camelCase.

help me debug TypeScript errors in my route file

✔️ Awesome, I've got everything!

Great. Double check you've saved all three files before moving on to testing.

ⓧ I'd like to double check the full code

Here's what each file should look like at this point.

import jwt from "jsonwebtoken";

const JWT_SECRET = process.env.NHOST_JWT_SECRET
  ? JSON.parse(process.env.NHOST_JWT_SECRET).key
  : process.env.JWT_SECRET!;

interface DecodedToken {
  sub: string;
  "https://hasura.io/jwt/claims": {
    "x-hasura-allowed-roles": string[];
    "x-hasura-default-role": string;
    "x-hasura-user-id": string;
    "x-hasura-operator-id"?: string;
  };
  exp: number;
  iat: number;
}

interface UserContext {
  userId: string;
  role: string;
  allowedRoles: string[];
  operatorId?: string;
}

export function verifyJwt(token: string): DecodedToken | null {
  try {
    const decoded = jwt.verify(token, JWT_SECRET) as DecodedToken;
    return decoded;
  } catch {
    return null;
  }
}

export function getUserContext(decoded: DecodedToken): UserContext {
  const claims = decoded["https://hasura.io/jwt/claims"];
  return {
    userId: claims["x-hasura-user-id"],
    role: claims["x-hasura-default-role"],
    allowedRoles: claims["x-hasura-allowed-roles"],
    operatorId: claims["x-hasura-operator-id"],
  };
}
const HASURA_GRAPHQL_URL =
  process.env.HASURA_GRAPHQL_URL || "http://hasura:8080/v1/graphql";
const HASURA_ADMIN_SECRET = process.env.HASURA_GRAPHQL_ADMIN_SECRET!;

export const hasuraAdminClient = {
  async request(query: string, variables: Record<string, unknown> = {}) {
    const response = await fetch(HASURA_GRAPHQL_URL, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "x-hasura-admin-secret": HASURA_ADMIN_SECRET,
      },
      body: JSON.stringify({ query, variables }),
    });

    const json = await response.json();

    if (json.errors) {
      throw new Error(json.errors[0]?.message || "GraphQL error");
    }

    return json.data;
  },
};
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { verifyJwt, getUserContext } from "@/lib/auth";
import { hasuraAdminClient } from "@/lib/hasura-client";

/**
 * SECURITY AUDIT CHECKLIST:
 * ✅ 1. JWT verified? — Yes (verifyJwt)
 * ✅ 2. Role checked? — Yes (operator_admin or property_manager)
 * ✅ 3. Data scoped? — Yes (verifyPropertyAccess)
 * ✅ 4. Input validated? — Yes (Zod schema)
 * ✅ 5. Errors handled? — Yes (try/catch with proper status codes)
 */

const bodySchema = z.object({
  tenant_id: z.string().uuid("tenant_id must be a valid UUID"),
  unit_id: z.string().uuid("unit_id must be a valid UUID"),
  property_id: z.string().uuid("property_id must be a valid UUID"),
  lease_start: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Must be YYYY-MM-DD"),
  lease_end: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Must be YYYY-MM-DD"),
  monthly_rent: z.number().positive("Rent must be positive"),
});

export async function POST(request: NextRequest) {
  const token = request.headers.get("authorization")?.replace("Bearer ", "");
  if (!token) {
    return NextResponse.json({ error: "No token provided" }, { status: 401 });
  }

  const decoded = verifyJwt(token);
  if (!decoded) {
    return NextResponse.json({ error: "Invalid token" }, { status: 401 });
  }

  const ctx = getUserContext(decoded);
  const allowedRoles = ["operator_admin", "property_manager"];
  if (!allowedRoles.includes(ctx.role)) {
    return NextResponse.json({ error: "Forbidden" }, { status: 403 });
  }

  let input: z.infer<typeof bodySchema>;
  try {
    input = bodySchema.parse(await request.json());
  } catch (err) {
    if (err instanceof z.ZodError) {
      return NextResponse.json(
        { error: "Invalid input", issues: err.issues },
        { status: 400 }
      );
    }
    return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
  }

  const hasAccess = await verifyPropertyAccess(ctx.userId, input.property_id);
  if (!hasAccess) {
    return NextResponse.json(
      { error: "You don't have access to this property" },
      { status: 403 }
    );
  }

  try {
    const result = await assignTenantToUnit(input, ctx.userId);
    return NextResponse.json(result, { status: 200 });
  } catch (err) {
    const message = err instanceof Error ? err.message : "Unknown error";
    console.error("[assign-unit] failed:", message);
    return NextResponse.json({ error: message }, { status: 500 });
  }
}

async function verifyPropertyAccess(
  userId: string,
  propertyId: string
): Promise<boolean> {
  const query = `
    query CheckAccess($userId: uuid!, $propertyId: uuid!) {
      property_members(
        where: {
          user_id: { _eq: $userId }
          property_id: { _eq: $propertyId }
        }
      ) {
        id
      }
    }
  `;
  const data = await hasuraAdminClient.request(query, { userId, propertyId });
  return data.property_members.length > 0;
}

async function assignTenantToUnit(
  input: z.infer<typeof bodySchema>,
  assignedBy: string
) {
  const mutation = `
    mutation AssignTenant(
      $tenantId: uuid!
      $unitId: uuid!
      $propertyId: uuid!
      $leaseStart: date!
      $leaseEnd: date!
      $monthlyRent: numeric!
      $assignedBy: uuid!
    ) {
      insert_leases_one(object: {
        tenant_id: $tenantId
        unit_id: $unitId
        property_id: $propertyId
        start_date: $leaseStart
        end_date: $leaseEnd
        monthly_rent: $monthlyRent
        status: "active"
        created_by: $assignedBy
      }) {
        id
        status
      }
      update_units_by_pk(
        pk_columns: { id: $unitId }
        _set: { status: "occupied", current_tenant_id: $tenantId }
      ) {
        id
        status
      }
    }
  `;
  return hasuraAdminClient.request(mutation, {
    tenantId: input.tenant_id,
    unitId: input.unit_id,
    propertyId: input.property_id,
    leaseStart: input.lease_start,
    leaseEnd: input.lease_end,
    monthlyRent: input.monthly_rent,
    assignedBy,
  });
}
Test each security layer

The best way to understand these layers is to watch each one reject a bad request. You'll hit the API four different ways to trigger each layer's error response.

  • Open a terminal in Cursor. First, test Layer 1 by sending a request with no token:
curl -X POST http://localhost:3000/api/tenants/assign-unit \
  -H "Content-Type: application/json" \
  -d '{"tenant_id": "test"}'

You should see a 401 response with the message "No token provided". Layer 1 stopped the request before anything else ran.

  • Now test with an invalid (fake) JWT token:
curl -X POST http://localhost:3000/api/tenants/assign-unit \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer fake-token-12345" \
  -d '{"tenant_id": "test"}'

You should see a 401 response with "Invalid token". The verifyJwt function caught the tampered token and returned null.

How to test Layers 2, 3, and 5

To test the role check (Layer 2), you need a valid JWT with the wrong role. Log into your app as a user with a tenant role, copy the access token from your browser's DevTools (Application tab, look for the Nhost session), and use it in the curl command. You'll get a 403 "Forbidden" response.

To test data scoping (Layer 3), use a valid JWT from a property manager but pass a property_id for a property they don't belong to. You'll get a 403 "You don't have access to this property" response.

To test input validation (Layer 4), use a valid JWT but send malformed data (like "tenant_id": "not-a-uuid"). You'll get a 400 with Zod's detailed error issues.

  • Try at least one more test using a real JWT from your app. Copy your access token from your browser's DevTools and run a curl command that should succeed or fail based on the layer you want to test.

Getting connection refused on localhost:3000?

Make sure your Next.js dev server is still running. Check the terminal where you started it. If it crashed, restart it.

help me debug why my curl requests aren't reaching my Next.js API route

You've now built a complete 5-layer secure API route and watched each layer do its job. Next up, you'll explore what happens when these layers are MISSING.

Understand What Happens Without Each Layer

Your API route has five security layers, and each one blocks a specific type of attack. But what does "blocked" actually look like in practice?

In this step, you'll temporarily remove layers to see exactly what goes wrong. This builds the instinct you need to spot missing layers when auditing code that Claude generates for you.

In this step, get ready to:
  • See what happens when JWT verification is removed.
  • Understand why data scoping matters beyond role checks.
  • Review the 5-question security audit checklist for auditing any API route.
What happens without a JWT check

Layer 1 (Authentication) proves WHO is calling your API. Without it, anyone who knows the URL can make requests. Having a "hard to guess" URL is not security.

  • In Cursor, open app/api/tenants/assign-unit/route.ts.
  • Find the JWT verification block at the top of the POST function. It starts with this code:
const token = request.headers.get("authorization")?.replace("Bearer ", "");
if (!token) {
  return NextResponse.json({ error: "No token provided" }, { status: 401 });
}

const decoded = verifyJwt(token);
if (!decoded) {
  return NextResponse.json({ error: "Invalid token" }, { status: 401 });
}
  • Temporarily comment out this entire block by selecting all 8 lines and pressing Cmd+/ (macOS) or Ctrl+/ (Windows).
  • Also comment out the line const ctx = getUserContext(decoded); and temporarily hardcode a fake context by adding this line in its place:
// TEMPORARY: Fake context to simulate no auth
const ctx = { userId: "fake-user", role: "operator_admin", allowedRoles: ["operator_admin"], operatorId: undefined };
  • Save the file.
  • Now test it by sending a request with NO Authorization header. Run this in your terminal:
curl -X POST http://localhost:3000/api/tenants/assign-unit \
  -H "Content-Type: application/json" \
  -d '{"tenant_id": "00000000-0000-0000-0000-000000000001", "unit_id": "00000000-0000-0000-0000-000000000002", "property_id": "00000000-0000-0000-0000-000000000003", "lease_start": "2025-01-01", "lease_end": "2026-01-01", "monthly_rent": 1500}'

The request passes input validation and proceeds to the business logic layers. Without the JWT check, anyone on the internet who discovers this URL can call your API.

Why is a hard-to-guess URL not security?

URLs are logged in browser history, server access logs, proxy caches, and network monitoring tools. They also appear in error reports and analytics dashboards.

Security through obscurity fails the moment someone inspects network traffic or finds the URL in frontend JavaScript. The JWT check is the real gate.

  • Revert your changes now. Uncomment the JWT verification block and remove the temporary hardcoded context line.
  • Restore the original const ctx = getUserContext(decoded); line.
  • Save route.ts.

Forgot what the original code looked like?

Press Cmd+Z (macOS) or Ctrl+Z (Windows) repeatedly to undo all your changes. You can also check the full code tab at the end of this step.

Help me revert my route.ts back to the original 5-layer version

What happens without data scoping

Layer 3 (Data Scoping) is the layer most often missing from AI-generated code. Role checks alone confirm that someone IS a property manager. Data scoping confirms they manage THIS specific property.

  • In route.ts, find the data scoping check:
const hasAccess = await verifyPropertyAccess(ctx.userId, input.property_id);
if (!hasAccess) {
  return NextResponse.json(
    { error: "You don't have access to this property" },
    { status: 403 }
  );
}
  • Temporarily comment out these lines using Cmd+/ (macOS) or Ctrl+/ (Windows).
  • Save the file.

Now imagine this scenario: a property manager for Building A sends a request with Building B's property_id. Without the verifyPropertyAccess check, that request sails through to the business logic. The role check only verified "yes, they are a property manager." It never asked "are they a manager of THIS property?"

Why role checks alone aren't enough

Role-based access control (RBAC) answers "what CAN this person do?" Data scoping answers "what data can this person TOUCH?" Both are needed.

In a multi-tenant app, skipping data scoping means any authenticated user with the right role can read or modify another organization's data. This is one of the most common vulnerabilities in SaaS applications.

  • Revert your changes. Uncomment the verifyPropertyAccess block.
  • Save route.ts.
Review your security audit checklist

You now understand what each layer prevents. The comment block at the top of your route.ts captures this as a reusable 5-question checklist you can paste at the top of any API route.

  • Scroll to the top of app/api/tenants/assign-unit/route.ts and find this comment block:
/**
 * SECURITY AUDIT CHECKLIST:
 * ✅ 1. JWT verified? — Yes (verifyJwt)
 * ✅ 2. Role checked? — Yes (operator_admin or property_manager)
 * ✅ 3. Data scoped? — Yes (verifyPropertyAccess)
 * ✅ 4. Input validated? — Yes (Zod schema)
 * ✅ 5. Errors handled? — Yes (try/catch with proper status codes)
 */

How to use this checklist

Every time Claude generates an API route for you, paste this comment block at the top and answer each question. If any answer is "No", you've found a gap to fix before deploying.

  • Question 1 (JWT verified?) catches the "anyone can call it" vulnerability you just tested.
  • Question 2 (Role checked?) catches routes where any logged-in user can perform admin actions.
  • Question 3 (Data scoped?) catches the cross-property access vulnerability you just explored.
  • Question 4 (Input validated?) catches garbage or malicious data reaching your database.
  • Question 5 (Errors handled?) catches routes that leak stack traces or crash without a response.

The most commonly missed layer

In practice, AI-generated code almost always includes JWT verification (Layer 1) and input validation (Layer 4). The layer most often missing is data scoping (Layer 3). Always check: "Does this route verify the user can access THIS specific resource, not just any resource of this type?"

✔️ Awesome, I've got everything!

Make sure you've reverted all temporary changes and saved your file.

ⓧ I'd like to double check the full code

import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { verifyJwt, getUserContext } from "@/lib/auth";
import { hasuraAdminClient } from "@/lib/hasura-client";

/**
 * SECURITY AUDIT CHECKLIST:
 * ✅ 1. JWT verified? — Yes (verifyJwt)
 * ✅ 2. Role checked? — Yes (operator_admin or property_manager)
 * ✅ 3. Data scoped? — Yes (verifyPropertyAccess)
 * ✅ 4. Input validated? — Yes (Zod schema)
 * ✅ 5. Errors handled? — Yes (try/catch with proper status codes)
 */

const bodySchema = z.object({
  tenant_id: z.string().uuid("tenant_id must be a valid UUID"),
  unit_id: z.string().uuid("unit_id must be a valid UUID"),
  property_id: z.string().uuid("property_id must be a valid UUID"),
  lease_start: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Must be YYYY-MM-DD"),
  lease_end: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Must be YYYY-MM-DD"),
  monthly_rent: z.number().positive("Rent must be positive"),
});

export async function POST(request: NextRequest) {
  const token = request.headers.get("authorization")?.replace("Bearer ", "");
  if (!token) {
    return NextResponse.json({ error: "No token provided" }, { status: 401 });
  }

  const decoded = verifyJwt(token);
  if (!decoded) {
    return NextResponse.json({ error: "Invalid token" }, { status: 401 });
  }

  const ctx = getUserContext(decoded);
  const allowedRoles = ["operator_admin", "property_manager"];
  if (!allowedRoles.includes(ctx.role)) {
    return NextResponse.json({ error: "Forbidden" }, { status: 403 });
  }

  let input: z.infer<typeof bodySchema>;
  try {
    input = bodySchema.parse(await request.json());
  } catch (err) {
    if (err instanceof z.ZodError) {
      return NextResponse.json(
        { error: "Invalid input", issues: err.issues },
        { status: 400 }
      );
    }
    return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
  }

  const hasAccess = await verifyPropertyAccess(ctx.userId, input.property_id);
  if (!hasAccess) {
    return NextResponse.json(
      { error: "You don't have access to this property" },
      { status: 403 }
    );
  }

  try {
    const result = await assignTenantToUnit(input, ctx.userId);
    return NextResponse.json(result, { status: 200 });
  } catch (err) {
    const message = err instanceof Error ? err.message : "Unknown error";
    console.error("[assign-unit] failed:", message);
    return NextResponse.json({ error: message }, { status: 500 });
  }
}

async function verifyPropertyAccess(
  userId: string,
  propertyId: string
): Promise<boolean> {
  const query = `
    query CheckAccess($userId: uuid!, $propertyId: uuid!) {
      property_members(
        where: {
          user_id: { _eq: $userId }
          property_id: { _eq: $propertyId }
        }
      ) {
        id
      }
    }
  `;
  const data = await hasuraAdminClient.request(query, { userId, propertyId });
  return data.property_members.length > 0;
}

async function assignTenantToUnit(
  input: z.infer<typeof bodySchema>,
  assignedBy: string
) {
  const mutation = `
    mutation AssignTenant(
      $tenantId: uuid!
      $unitId: uuid!
      $propertyId: uuid!
      $leaseStart: date!
      $leaseEnd: date!
      $monthlyRent: numeric!
      $assignedBy: uuid!
    ) {
      insert_leases_one(object: {
        tenant_id: $tenantId
        unit_id: $unitId
        property_id: $propertyId
        start_date: $leaseStart
        end_date: $leaseEnd
        monthly_rent: $monthlyRent
        status: "active"
        created_by: $assignedBy
      }) {
        id
        status
      }
      update_units_by_pk(
        pk_columns: { id: $unitId }
        _set: { status: "occupied", current_tenant_id: $tenantId }
      ) {
        id
        status
      }
    }
  `;
  return hasuraAdminClient.request(mutation, {
    tenantId: input.tenant_id,
    unitId: input.unit_id,
    propertyId: input.property_id,
    leaseStart: input.lease_start,
    leaseEnd: input.lease_end,
    monthlyRent: input.monthly_rent,
    assignedBy,
  });
}

You now know what each security layer prevents and have a checklist to audit any route. Next, you'll build the same operation as a Hasura Action to see how Hasura handles some of these layers for you.

Build the Same Operation as a Hasura Action

You now understand exactly how the direct API route handles security across all 5 layers. In this step, you'll rebuild the exact same "assign tenant to unit" operation as a Hasura Action.

A Hasura Action extends your GraphQL schema with custom business logic backed by an HTTP webhook. The key difference is that Hasura handles JWT verification and role-based permissions for you, so your handler can focus on data scoping, validation, and the actual work.

In this step, get ready to:
  • Define the assignTenantToUnit action in the Hasura Console with input and output types.
  • Write the action handler as a Next.js API route that receives session variables from Hasura.
  • Call the action from the frontend using a standard GraphQL mutation and compare both approaches.
Define the action in Hasura Console

When you create a Hasura Action, you define a GraphQL mutation signature (the inputs and output type) and point it at a webhook URL. Hasura then exposes that mutation in your schema and handles authentication before forwarding the request to your handler.

  • Switch to your Hasura Console at http://localhost:8080.
  • Click the Actions tab in the top navigation bar.
  • Click Create to open the action creation page.
  • In the Action Definition editor, replace the placeholder text with the following mutation definition:
type Mutation {
  assignTenantToUnit(
    tenant_id: uuid!
    unit_id: uuid!
    property_id: uuid!
    lease_start: String!
    lease_end: String!
    monthly_rent: numeric!
  ): AssignTenantOutput
}
  • In the New types definition editor below, replace the placeholder text with the output type:
type AssignTenantOutput {
  lease_id: uuid!
  unit_status: String!
  success: Boolean!
}

What do these definitions do?

The mutation definition tells Hasura the exact shape of the data your action accepts. The output type tells Hasura what your handler will return so it can validate the response before sending it to the frontend.

Notice the types match what your existing direct API route returns: a lease ID, the updated unit status, and a success flag.

  • Scroll down to the Webhook Handler field.
  • Enter the following handler URL:
http://host.docker.internal:3000/api/actions/assign-unit

Why host.docker.internal?

Hasura runs inside a Docker container. To reach your Next.js dev server running on the host machine (port 3000), you use host.docker.internal instead of localhost.

From inside Docker, localhost refers to the container itself, not your computer.

  • Click Create at the bottom of the page to save the action.

Now you need to configure which roles can call this action.

  • Click the Permissions tab on the newly created action's page.
  • Enable access for the operator_admin role.
  • Enable access for the property_manager role.
  • Click Save to apply the permissions.

What do these permissions do?

This is the role-based authorization layer that Hasura handles for you. Only users with operator_admin or property_manager in their JWT claims can call this mutation.

In your direct API route, you wrote this check manually. With Actions, Hasura does it before your handler is even called.

Don't see the Permissions tab?

Make sure you clicked Create to save the action first. The Permissions tab only appears after the action exists.

help me find the Permissions tab for my Hasura Action

Write the action handler

When someone calls your Hasura Action via GraphQL, Hasura sends a POST request to your handler URL. The request body contains the action inputs and the caller's session variables (user ID, role) that Hasura extracted from their JWT.

  • In Cursor, create a new file at app/api/actions/assign-unit/route.ts.

Why a different path?

Your direct API route lives at app/api/tenants/assign-unit/route.ts. Your action handler lives at app/api/actions/assign-unit/route.ts.

Keeping action handlers in their own actions/ folder makes it clear which routes are called by Hasura vs called directly by your frontend.

  • Add the imports, doc comment, and configuration to app/api/actions/assign-unit/route.ts by pasting the following code:
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { hasuraAdminClient } from "@/lib/hasura-client";

/**
 * HASURA ACTION HANDLER
 *
 * KEY DIFFERENCES from the direct API route:
 * 1. Hasura already verified the JWT (you don't need to)
 * 2. Hasura sends you session_variables (user ID, role) in the request body
 * 3. The frontend calls this via a normal GraphQL mutation
 * 4. You still need: data scoping, input validation, business logic
 */

const ACTION_SECRET = process.env.ACTION_SECRET!;

const inputSchema = z.object({
  tenant_id: z.string().uuid(),
  unit_id: z.string().uuid(),
  property_id: z.string().uuid(),
  lease_start: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
  lease_end: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
  monthly_rent: z.number().positive(),
});

What does this code do?

  • No import of verifyJwt or getUserContext. Hasura already verified the JWT before calling your handler.
  • ACTION_SECRET reads a shared secret from your environment. You'll enforce this in the Secret Mission.
  • inputSchema validates the incoming data the same way your direct route does. Hasura does not validate your business rules for you.
  • Add the POST handler function below the schema definition:
export async function POST(request: NextRequest) {
  // Verify this request came from Hasura
  const secretHeader = request.headers.get("x-action-secret");
  if (secretHeader !== ACTION_SECRET) {
    return NextResponse.json({ message: "Forbidden" }, { status: 403 });
  }

  // Parse the Hasura Action payload
  const body = await request.json();
  const { input, session_variables } = body;

  // Hasura already verified the JWT! We get user info for free:
  const userId = session_variables["x-hasura-user-id"];
  const userRole = session_variables["x-hasura-role"];

  // LAYER 3: Data Scoping — still needed! Hasura doesn't do this for you.
  const hasAccess = await verifyPropertyAccess(userId, input.property_id);
  if (!hasAccess) {
    return NextResponse.json(
      { message: "You don't have access to this property" },
      { status: 403 }
    );
  }

  // LAYER 4: Input Validation — still needed!
  const parsed = inputSchema.safeParse(input);
  if (!parsed.success) {
    return NextResponse.json(
      { message: "Invalid input: " + parsed.error.issues[0]?.message },
      { status: 400 }
    );
  }

  // LAYER 5: Business Logic — same as before
  try {
    const result = await assignTenantToUnit(parsed.data, userId);

    // Hasura Actions expect the response to match your output type
    return NextResponse.json({
      lease_id: result.insert_leases_one.id,
      unit_status: result.update_units_by_pk.status,
      success: true,
    });
  } catch (err) {
    const message = err instanceof Error ? err.message : "Unknown error";
    return NextResponse.json({ message }, { status: 500 });
  }
}

What does this code do?

  • The x-action-secret header check verifies that Hasura (not a random caller) sent this request.
  • session_variables is the payload Hasura sends containing the user's ID and role. These come straight from the JWT that Hasura already verified.
  • You still perform data scoping (Layer 3) and input validation (Layer 4) manually. Hasura only handles Layers 1 and 2.
  • Error responses use { message: "..." } format because that is what Hasura Actions expect for error payloads.
  • Add the helper functions below the POST handler:
// Same helper functions as before (verifyPropertyAccess, assignTenantToUnit)
async function verifyPropertyAccess(
  userId: string,
  propertyId: string
): Promise<boolean> {
  const query = `
    query CheckAccess($userId: uuid!, $propertyId: uuid!) {
      property_members(
        where: {
          user_id: { _eq: $userId }
          property_id: { _eq: $propertyId }
        }
      ) {
        id
      }
    }
  `;
  const data = await hasuraAdminClient.request(query, { userId, propertyId });
  return data.property_members.length > 0;
}

async function assignTenantToUnit(
  input: z.infer<typeof inputSchema>,
  assignedBy: string
) {
  const mutation = `
    mutation AssignTenant(
      $tenantId: uuid!
      $unitId: uuid!
      $propertyId: uuid!
      $leaseStart: date!
      $leaseEnd: date!
      $monthlyRent: numeric!
      $assignedBy: uuid!
    ) {
      insert_leases_one(object: {
        tenant_id: $tenantId
        unit_id: $unitId
        property_id: $propertyId
        start_date: $leaseStart
        end_date: $leaseEnd
        monthly_rent: $monthlyRent
        status: "active"
        created_by: $assignedBy
      }) {
        id
        status
      }
      update_units_by_pk(
        pk_columns: { id: $unitId }
        _set: { status: "occupied", current_tenant_id: $tenantId }
      ) {
        id
        status
      }
    }
  `;

  return hasuraAdminClient.request(mutation, {
    tenantId: input.tenant_id,
    unitId: input.unit_id,
    propertyId: input.property_id,
    leaseStart: input.lease_start,
    leaseEnd: input.lease_end,
    monthlyRent: input.monthly_rent,
    assignedBy,
  });
}

What does this code do?

These are the same helper functions from your direct API route. verifyPropertyAccess checks that the user belongs to the property they're trying to modify. assignTenantToUnit performs the actual database operations using the admin secret.

The business logic is identical whether you use a direct API route or a Hasura Action. The difference is only in how the request arrives at your code.

  • Save app/api/actions/assign-unit/route.ts.

✔️ Awesome, I've got everything!

Great. Double check you've saved the file.

ⓧ I'd like to double check the full code

import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { hasuraAdminClient } from "@/lib/hasura-client";

/**
 * HASURA ACTION HANDLER
 *
 * KEY DIFFERENCES from the direct API route:
 * 1. Hasura already verified the JWT (you don't need to)
 * 2. Hasura sends you session_variables (user ID, role) in the request body
 * 3. The frontend calls this via a normal GraphQL mutation
 * 4. You still need: data scoping, input validation, business logic
 */

const ACTION_SECRET = process.env.ACTION_SECRET!;

const inputSchema = z.object({
  tenant_id: z.string().uuid(),
  unit_id: z.string().uuid(),
  property_id: z.string().uuid(),
  lease_start: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
  lease_end: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
  monthly_rent: z.number().positive(),
});

export async function POST(request: NextRequest) {
  const secretHeader = request.headers.get("x-action-secret");
  if (secretHeader !== ACTION_SECRET) {
    return NextResponse.json({ message: "Forbidden" }, { status: 403 });
  }

  const body = await request.json();
  const { input, session_variables } = body;

  const userId = session_variables["x-hasura-user-id"];
  const userRole = session_variables["x-hasura-role"];

  const hasAccess = await verifyPropertyAccess(userId, input.property_id);
  if (!hasAccess) {
    return NextResponse.json(
      { message: "You don't have access to this property" },
      { status: 403 }
    );
  }

  const parsed = inputSchema.safeParse(input);
  if (!parsed.success) {
    return NextResponse.json(
      { message: "Invalid input: " + parsed.error.issues[0]?.message },
      { status: 400 }
    );
  }

  try {
    const result = await assignTenantToUnit(parsed.data, userId);
    return NextResponse.json({
      lease_id: result.insert_leases_one.id,
      unit_status: result.update_units_by_pk.status,
      success: true,
    });
  } catch (err) {
    const message = err instanceof Error ? err.message : "Unknown error";
    return NextResponse.json({ message }, { status: 500 });
  }
}

async function verifyPropertyAccess(
  userId: string,
  propertyId: string
): Promise<boolean> {
  const query = `
    query CheckAccess($userId: uuid!, $propertyId: uuid!) {
      property_members(
        where: {
          user_id: { _eq: $userId }
          property_id: { _eq: $propertyId }
        }
      ) {
        id
      }
    }
  `;
  const data = await hasuraAdminClient.request(query, { userId, propertyId });
  return data.property_members.length > 0;
}

async function assignTenantToUnit(
  input: z.infer<typeof inputSchema>,
  assignedBy: string
) {
  const mutation = `
    mutation AssignTenant(
      $tenantId: uuid!
      $unitId: uuid!
      $propertyId: uuid!
      $leaseStart: date!
      $leaseEnd: date!
      $monthlyRent: numeric!
      $assignedBy: uuid!
    ) {
      insert_leases_one(object: {
        tenant_id: $tenantId
        unit_id: $unitId
        property_id: $propertyId
        start_date: $leaseStart
        end_date: $leaseEnd
        monthly_rent: $monthlyRent
        status: "active"
        created_by: $assignedBy
      }) {
        id
        status
      }
      update_units_by_pk(
        pk_columns: { id: $unitId }
        _set: { status: "occupied", current_tenant_id: $tenantId }
      ) {
        id
        status
      }
    }
  `;

  return hasuraAdminClient.request(mutation, {
    tenantId: input.tenant_id,
    unitId: input.unit_id,
    propertyId: input.property_id,
    leaseStart: input.lease_start,
    leaseEnd: input.lease_end,
    monthlyRent: input.monthly_rent,
    assignedBy,
  });
}

Seeing a module not found error?

Make sure the app/api/actions/ folder exists. You may need to create the actions directory inside app/api/ before creating the assign-unit subfolder.

help me fix a module import error in my Hasura Action handler

Call the action from the frontend

The best part of a Hasura Action is how it looks from the frontend's perspective. Your frontend calls it using a standard GraphQL mutation, identical to how it calls any other Hasura mutation.

Here is how the frontend calls your Hasura Action:

// Frontend code — calling the Hasura Action
const ASSIGN_TENANT = gql`
  mutation AssignTenantToUnit(
    $tenantId: uuid!
    $unitId: uuid!
    $propertyId: uuid!
    $leaseStart: String!
    $leaseEnd: String!
    $monthlyRent: numeric!
  ) {
    assignTenantToUnit(
      tenant_id: $tenantId
      unit_id: $unitId
      property_id: $propertyId
      lease_start: $leaseStart
      lease_end: $leaseEnd
      monthly_rent: $monthlyRent
    ) {
      lease_id
      unit_status
      success
    }
  }
`;

// Use it exactly like any other mutation
const [assignTenant] = useMutation(ASSIGN_TENANT);

Compare that to how the frontend calls the direct API route:

// Frontend code — calling the direct API route
const response = await fetch("/api/tenants/assign-unit", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: `Bearer ${token}`,
  },
  body: JSON.stringify({
    tenant_id: tenantId,
    unit_id: unitId,
    property_id: propertyId,
    lease_start: leaseStart,
    lease_end: leaseEnd,
    monthly_rent: monthlyRent,
  }),
});

What's the difference from the frontend's perspective?

  • The Hasura Action version uses useMutation from Apollo. The frontend never deals with REST endpoints, headers, or token management directly.
  • The direct API route version uses fetch. The frontend must manually attach the JWT in the Authorization header.
  • With the Action approach, Apollo Client already handles token injection because it sends all GraphQL requests through the same authenticated client.
  • Return to the Hasura Console and click the API tab to open GraphiQL.
  • Run the following mutation to test your action (use valid UUIDs from your database):
mutation {
  assignTenantToUnit(
    tenant_id: "your-tenant-uuid"
    unit_id: "your-unit-uuid"
    property_id: "your-property-uuid"
    lease_start: "2025-01-01"
    lease_end: "2026-01-01"
    monthly_rent: 1500
  ) {
    lease_id
    unit_status
    success
  }
}

Use the x-hasura-admin-secret header

In GraphiQL, make sure the x-hasura-admin-secret header is active so Hasura can forward a valid session to your handler. Replace the placeholder UUIDs with real values from your tenants, units, and properties tables.

  • Confirm you see a successful response with lease_id, unit_status, and success: true in the response panel.

Getting a Forbidden or connection error?

If you see a 403 Forbidden, your ACTION_SECRET environment variable may not be set in your Next.js .env file. Add ACTION_SECRET with any value for now.

If you see a connection refused error, make sure your Next.js dev server is running on port 3000. Hasura inside Docker needs the server to be accessible at host.docker.internal:3000.

help me debug my Hasura Action connection

Your Hasura Action is working. Next up, you'll build a decision framework for when to use each approach and add rate limiting to protect your self-hosted APIs.

Choose Your Approach and Add Rate Limiting

You've now built the same operation two ways and called both from the frontend. The next question is: how do you decide which approach to reach for when Claude generates a new API?

In this step, you'll build a simple decision framework. You'll also add one more security layer that self-hosted setups need: rate limiting.

In this step, get ready to:
  • Build a decision tree for Direct API Route vs Hasura Action.
  • Add an in-memory rate limiter to your direct API route.
Build your decision framework

Both approaches work. The right choice depends on who is calling your backend and what kind of work the operation does.

Use a Direct API Route when:

  • External services send webhooks to your backend (Stripe payment confirmations, calendar sync events).
  • Your route handles file uploads or binary data.
  • Cron jobs or scheduled tasks call the endpoint.
  • Non-GraphQL clients (mobile apps, third-party integrations) need to call it.

Use a Hasura Action when:

  • Your frontend needs atomic business logic that feels like a regular GraphQL mutation.
  • You want Hasura to handle JWT verification and role-based permissions automatically.
  • The operation fits naturally into your GraphQL schema (the frontend already uses useMutation for everything else).

What's the quick summary?

Direct API routes are for anything non-frontend or non-GraphQL. Hasura Actions are for frontend operations that need server-side logic but should still look like normal mutations to your React code.

When auditing code Claude generates, ask: "Does my frontend call this through Apollo?" If yes, a Hasura Action is likely the better fit. If something external calls it, use a direct route.

Add rate limiting to your direct API route

Rate limiting prevents a single client from flooding your API with requests. On Nhost Cloud, you get automatic rate limiting (100 requests per 15 minutes per client IP). Your self-hosted setup does not have this.

Without rate limiting, a malicious script could hammer your assign-unit endpoint thousands of times per second. You'll add a simple in-memory rate limiter that tracks request counts per IP address.

  • In app/api/tenants/assign-unit/route.ts, find the security audit checklist comment block at the top of the file.
  • Add the following rate limiter code directly below the closing */ of that comment block:
// Simple in-memory rate limiter
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
const RATE_LIMIT = 10; // requests
const RATE_WINDOW_MS = 60_000; // 1 minute

function checkRateLimit(ip: string): boolean {
  const now = Date.now();
  const entry = rateLimitMap.get(ip);
  if (!entry || now > entry.resetAt) {
    rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_WINDOW_MS });
    return true;
  }
  if (entry.count >= RATE_LIMIT) return false;
  entry.count++;
  return true;
}

What does this code do?

  • rateLimitMap is a JavaScript Map that stores each IP address alongside a request count and a reset timestamp.
  • checkRateLimit returns true if the IP is under the limit (10 requests per minute) and false if they've exceeded it.
  • When the time window expires, the counter resets automatically on the next request.

Now you need to call this function at the very top of your POST handler, before any other checks run.

  • Find the first line inside the POST function:
export async function POST(request: NextRequest) {
  const token = request.headers.get("authorization")?.replace("Bearer ", "");
  • Add the rate limit check between the function opening and the token line, so it looks like this:
export async function POST(request: NextRequest) {
  const ip = request.headers.get("x-forwarded-for") ?? "unknown";
  if (!checkRateLimit(ip)) {
    return NextResponse.json({ error: "Too many requests" }, { status: 429 });
  }

  const token = request.headers.get("authorization")?.replace("Bearer ", "");

Why is rate limiting the very first check?

Rate limiting runs before JWT verification on purpose. If an attacker is flooding your endpoint with garbage tokens, you want to block them before wasting CPU cycles on cryptographic signature checks.

The x-forwarded-for header contains the client's real IP address when your app sits behind a reverse proxy or load balancer.

  • Save app/api/tenants/assign-unit/route.ts.
  • Test the rate limiter by calling the endpoint more than 10 times within one minute. On the 11th request, you should receive a 429 status code with the message Too many requests.

Not seeing a 429 response?

  • Make sure you placed the rate limit check at the very top of the POST function, before the token extraction line.
  • Confirm that RATE_LIMIT is set to 10 and RATE_WINDOW_MS is set to 60_000. If you changed these values, you may need more requests to trigger the limit.
  • If you restarted your dev server between calls, the in-memory Map resets. All 10+ calls must happen within the same server session.

Help me debug my rate limiter

Why not use this in production?

This in-memory rate limiter works for a single server instance. In production with multiple server instances, the Map would be different on each one. A real production setup would use Redis or a similar shared store. For a self-hosted single-instance stack, this approach is a solid starting point.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file.

ⓧ I'd like to double check the full code

import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { verifyJwt, getUserContext } from "@/lib/auth";
import { hasuraAdminClient } from "@/lib/hasura-client";

/**
 * SECURITY AUDIT CHECKLIST:
 * ✅ 1. JWT verified? — Yes (verifyJwt)
 * ✅ 2. Role checked? — Yes (operator_admin or property_manager)
 * ✅ 3. Data scoped? — Yes (verifyPropertyAccess)
 * ✅ 4. Input validated? — Yes (Zod schema)
 * ✅ 5. Errors handled? — Yes (try/catch with proper status codes)
 */

// Simple in-memory rate limiter
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
const RATE_LIMIT = 10; // requests
const RATE_WINDOW_MS = 60_000; // 1 minute

function checkRateLimit(ip: string): boolean {
  const now = Date.now();
  const entry = rateLimitMap.get(ip);
  if (!entry || now > entry.resetAt) {
    rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_WINDOW_MS });
    return true;
  }
  if (entry.count >= RATE_LIMIT) return false;
  entry.count++;
  return true;
}

const bodySchema = z.object({
  tenant_id: z.string().uuid("tenant_id must be a valid UUID"),
  unit_id: z.string().uuid("unit_id must be a valid UUID"),
  property_id: z.string().uuid("property_id must be a valid UUID"),
  lease_start: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Must be YYYY-MM-DD"),
  lease_end: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Must be YYYY-MM-DD"),
  monthly_rent: z.number().positive("Rent must be positive"),
});

export async function POST(request: NextRequest) {
  const ip = request.headers.get("x-forwarded-for") ?? "unknown";
  if (!checkRateLimit(ip)) {
    return NextResponse.json({ error: "Too many requests" }, { status: 429 });
  }

  const token = request.headers.get("authorization")?.replace("Bearer ", "");
  if (!token) {
    return NextResponse.json({ error: "No token provided" }, { status: 401 });
  }

  const decoded = verifyJwt(token);
  if (!decoded) {
    return NextResponse.json({ error: "Invalid token" }, { status: 401 });
  }

  const ctx = getUserContext(decoded);
  const allowedRoles = ["operator_admin", "property_manager"];
  if (!allowedRoles.includes(ctx.role)) {
    return NextResponse.json({ error: "Forbidden" }, { status: 403 });
  }

  let input: z.infer<typeof bodySchema>;
  try {
    input = bodySchema.parse(await request.json());
  } catch (err) {
    if (err instanceof z.ZodError) {
      return NextResponse.json(
        { error: "Invalid input", issues: err.issues },
        { status: 400 }
      );
    }
    return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
  }

  const hasAccess = await verifyPropertyAccess(ctx.userId, input.property_id);
  if (!hasAccess) {
    return NextResponse.json(
      { error: "You don't have access to this property" },
      { status: 403 }
    );
  }

  try {
    const result = await assignTenantToUnit(input, ctx.userId);
    return NextResponse.json(result, { status: 200 });
  } catch (err) {
    const message = err instanceof Error ? err.message : "Unknown error";
    console.error("[assign-unit] failed:", message);
    return NextResponse.json({ error: message }, { status: 500 });
  }
}

async function verifyPropertyAccess(
  userId: string,
  propertyId: string
): Promise<boolean> {
  const query = `
    query CheckAccess($userId: uuid!, $propertyId: uuid!) {
      property_members(
        where: {
          user_id: { _eq: $userId }
          property_id: { _eq: $propertyId }
        }
      ) {
        id
      }
    }
  `;
  const data = await hasuraAdminClient.request(query, { userId, propertyId });
  return data.property_members.length > 0;
}

async function assignTenantToUnit(
  input: z.infer<typeof bodySchema>,
  assignedBy: string
) {
  const mutation = `
    mutation AssignTenant(
      $tenantId: uuid!
      $unitId: uuid!
      $propertyId: uuid!
      $leaseStart: date!
      $leaseEnd: date!
      $monthlyRent: numeric!
      $assignedBy: uuid!
    ) {
      insert_leases_one(object: {
        tenant_id: $tenantId
        unit_id: $unitId
        property_id: $propertyId
        start_date: $leaseStart
        end_date: $leaseEnd
        monthly_rent: $monthlyRent
        status: "active"
        created_by: $assignedBy
      }) {
        id
        status
      }
      update_units_by_pk(
        pk_columns: { id: $unitId }
        _set: { status: "occupied", current_tenant_id: $tenantId }
      ) {
        id
        status
      }
    }
  `;
  return hasuraAdminClient.request(mutation, {
    tenantId: input.tenant_id,
    unitId: input.unit_id,
    propertyId: input.property_id,
    leaseStart: input.lease_start,
    leaseEnd: input.lease_end,
    monthlyRent: input.monthly_rent,
    assignedBy,
  });
}

Your direct API route now has six layers of protection: rate limiting, JWT authentication, role authorization, data scoping, input validation, and business logic. You have a clear framework for deciding which approach to use. Next up, put your skills to the test with a bonus security challenge.

Secret mission

Add a Webhook Secret to Your Hasura Action Handler

Your Hasura Action handler is currently open to anyone who knows the URL. Right now, someone could bypass Hasura entirely and POST directly to your handler endpoint. In this secret mission, you'll lock it down with a webhook secret header that only Hasura knows.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally using Docker Compose, so there are no ongoing cloud costs. Your Docker containers do use system memory and CPU while running, so you may want to stop them when you're not actively developing.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Docker Compose services (Hasura, PostgreSQL, Nhost Auth, Storage) running locally.
  • Next.js API route: app/api/tenants/assign-unit/route.ts (direct API route with 5 security layers).
  • Next.js API route: app/api/actions/assign-unit/route.ts (Hasura Action handler).
  • Hasura Action assignTenantToUnit configured in the Hasura Console.
  • ACTION_SECRET environment variable in .env.local and Docker Compose.

Keep everything running

No action needed. Choose this if you're still actively building your app or plan to continue working on it soon.

  • Your Docker containers will keep running and using system memory.
  • All API routes, the Hasura Action, and your security audit checklist remain in place.
  • You can pick up right where you left off next time you open your project.

Pause - I'll come back to this later

Stop your Docker containers to free up system memory. Your files and configuration stay on disk so you can restart everything later.

  • Stop your Docker Compose services by running this in your project's Docker Compose directory:
docker compose stop
  • When you're ready to resume, start everything back up with:
docker compose start

All your data in PostgreSQL, your Hasura metadata, and your Next.js files will be exactly as you left them.

Delete - I don't want to use this again

Remove the resources this project created. This deletes only what you built during this project, not your entire pre-existing setup.

  • Remove the Hasura Action from the Console. Open http://localhost:8080, click Actions in the sidebar, select assignTenantToUnit, then click Delete.
  • Remove the ACTION_SECRET line from your .env.local file.
  • Remove the ACTION_SECRET entry from your Docker Compose environment variables for the Hasura service.
  • Delete the API route files created during this project by running:
rm -rf app/api/actions/assign-unit
rm -rf app/api/tenants/assign-unit
  • If you also want to remove the shared library files, run:
rm lib/auth.ts
rm lib/hasura-client.ts

If you want to tear down the entire Docker Compose stack (containers, volumes, and database data), run:

docker compose down -v

This removes everything

The -v flag deletes your PostgreSQL volumes, wiping all database data. Only run this if you're sure you don't need any of the data in your local Hasura/Postgres setup.

Nice Work!

Nice Work!

You now understand exactly how your backend APIs work. No more black boxes, no more blindly trusting AI-generated code without knowing what each layer does.

You've learned how to:

  • Apply the 5-layer security model to every API route: Authentication, Role Authorization, Data Scoping, Input Validation, and Business Logic.
  • Build the same operation as both a direct Next.js API route and a Hasura Action, knowing exactly when to reach for each approach.
  • Audit any API that Claude generates using your 5-question security checklist.
  • Secret Mission: Add a webhook secret header so only Hasura can call your action handlers.

Ready to quiz yourself?