# Security Best Practices for TanStack Start Drizzle SaaS Applications

*Canonical: https://makerkit.dev/docs/tanstack-drizzle/security/overview*

---

Security is fundamental to building a trustworthy SaaS application. Makerkit provides multiple layers of protection out of the box, from application-level authorization to data validation and security response headers.

This section covers the security architecture and best practices you need to understand to build and maintain a secure application.

## Security architecture overview

Makerkit implements a defense-in-depth security model with three primary layers:

| Layer | Purpose | Implementation |
|-------|---------|----------------|
| **Application** | Data access control & validation | Authorization middleware + Zod schemas |
| **Transport** | Request protection | CSRF middleware + security response headers |
| **Authentication** | Identity verification | Better Auth with optional MFA |

Each layer provides independent protection, so a failure in one layer doesn't compromise the entire system.

## Why security matters for multi-tenant applications

In a multi-tenant SaaS application like Makerkit, multiple customers share the same database and application infrastructure. This architecture requires careful security design to ensure:

1. **Data isolation**: One team's data must never be accessible to another team
2. **Permission enforcement**: Users can only perform actions their role allows
3. **Input sanitization**: Malicious input cannot compromise the system
4. **Session security**: Authentication tokens are protected and properly scoped

Makerkit addresses each of these concerns through its security architecture.

## Application-level authorization

Since the TanStack Start Drizzle SaaS Kit does not use Row Level Security (RLS), all authorization happens at the application layer. This means every data access function must include explicit authorization checks.

Makerkit provides server-function middleware in `@kit/function-middleware` for implementing authorization. Actions are built by continuing the standard TanStack Start `createServerFn` chain: apply a pre-composed middleware tuple to a literal `createServerFn(...)` call, add `.validator(schema)` to validate input, layer extra gates by spreading additional middleware into `.middleware([...])`, and implement with `.handler(async ({ data, context }) => ...)`.

### Authenticated server functions

Apply `authFunctionMiddleware` to ensure the user is authenticated and validate input:

```typescript
import { createServerFn } from '@tanstack/react-start';
import { authFunctionMiddleware } from '@kit/function-middleware/functions';
import { z } from 'zod';

const CreateDocumentSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string(),
});

export const createDocument = createServerFn({ method: 'POST' })
  .middleware(authFunctionMiddleware)
  .validator(CreateDocumentSchema)
  .handler(async ({ data, context }) => {
    // `data` is validated against CreateDocumentSchema
    // `context.user` contains the authenticated user
    const { user } = context;

    return insertDocument({
      ...data,
      userId: user.id,
    });
  });
```

### Organization-scoped actions

Apply `organizationFunctionMiddleware` for actions that require organization context:

```typescript
import { createServerFn } from '@tanstack/react-start';
import { organizationFunctionMiddleware } from '@kit/function-middleware/functions';
import { z } from 'zod';

const DeleteDocumentSchema = z.object({
  documentId: z.string().uuid(),
});

export const deleteDocument = createServerFn({ method: 'POST' })
  .middleware(organizationFunctionMiddleware)
  .validator(DeleteDocumentSchema)
  .handler(async ({ data, context }) => {
    // `context` includes user, organizationId, and role
    const { user, organizationId, role } = context;

    // Verify user has permission to delete
    if (role === 'member') {
      throw new Error('Insufficient permissions');
    }

    return removeDocument(data.documentId, organizationId);
  });
```

### Role-protected actions

Layer the `withMinRole` middleware for role-based access control:

```typescript
import { createServerFn } from '@tanstack/react-start';
import { organizationFunctionMiddleware } from '@kit/function-middleware/functions';
import { withMinRole } from '@kit/function-middleware/server';

export const ownerOnlyFunction = createServerFn({ method: 'POST' })
  .middleware([...organizationFunctionMiddleware, withMinRole('owner')])
  .validator(Schema)
  .handler(async ({ data, context }) => {
    // Only organization owners can reach here
  });
```

### Permission-protected actions

Layer the `withFeaturePermission` middleware for granular RBAC:

```typescript
import { createServerFn } from '@tanstack/react-start';
import { organizationFunctionMiddleware } from '@kit/function-middleware/functions';
import { withFeaturePermission } from '@kit/function-middleware/server';

export const billingFunction = createServerFn({ method: 'POST' })
  .middleware([...organizationFunctionMiddleware, withFeaturePermission({ billing: ['update'] })])
  .validator(Schema)
  .handler(async ({ data, context }) => {
    // Only users with billing:update permission
  });
```

### Organization membership verification

For server functions, apply `organizationFunctionMiddleware` which automatically verifies organization membership and injects the organization context:

```typescript
import { createServerFn } from '@tanstack/react-start';
import { organizationFunctionMiddleware } from '@kit/function-middleware/functions';

export const getDocumentsFunction = createServerFn({ method: 'POST' })
  .middleware(organizationFunctionMiddleware)
  .handler(async ({ context }) => {
    // context.organizationId is verified - user is a member
    // context.role contains the user's role in the organization
    return getDocuments(context.organizationId);
  });
```

For data access functions outside of actions, use `requireActiveOrganizationId`:

```typescript {% title="documents.server.ts" %}
import { eq } from 'drizzle-orm';

import { db, documents } from '@kit/database';
import { requireActiveOrganizationId } from '@kit/better-auth/context';

export async function getDocuments() {
  // Redirects to the app home path if user is not in an active organization
  const organizationId = await requireActiveOrganizationId();

  return db.query.documents.findMany({
    where: eq(documents.organizationId, organizationId),
  });
}
```

### Resource-level authorization checks

Without Row Level Security, you must verify the user has access to specific resources before performing operations. Always check resource ownership or organization membership:

```typescript
import { and, eq } from 'drizzle-orm';

import { createServerFn } from '@tanstack/react-start';
import { organizationFunctionMiddleware } from '@kit/function-middleware/functions';
import { db, documents } from '@kit/database';
import { z } from 'zod';

const UpdateDocumentSchema = z.object({
  documentId: z.string().uuid(),
  title: z.string().min(1).max(200),
});

export const updateDocument = createServerFn({ method: 'POST' })
  .middleware(organizationFunctionMiddleware)
  .validator(UpdateDocumentSchema)
  .handler(async ({ data, context }) => {
    const { documentId, title } = data;
    const { organizationId } = context;

    // Verify the document belongs to the user's organization
    const document = await db.query.documents.findFirst({
      where: and(
        eq(documents.id, documentId),
        eq(documents.organizationId, organizationId)
      ),
    });

    if (!document) {
      throw new Error('Document not found');
    }

    // Safe to update - ownership verified
    return db
      .update(documents)
      .set({ title })
      .where(eq(documents.id, documentId));
  });
```

For delete operations, combine ownership checks with role verification:

```typescript
import { and, eq } from 'drizzle-orm';

import { createServerFn } from '@tanstack/react-start';
import { organizationFunctionMiddleware } from '@kit/function-middleware/functions';
import { withMinRole } from '@kit/function-middleware/server';
import { db, documents } from '@kit/database';
import { z } from 'zod';

const DeleteDocumentSchema = z.object({
  documentId: z.string().uuid(),
});

export const deleteDocument = createServerFn({ method: 'POST' })
  .middleware([...organizationFunctionMiddleware, withMinRole('admin')])
  .validator(DeleteDocumentSchema)
  .handler(async ({ data, context }) => {
    const { documentId } = data;
    const { organizationId } = context;

    // Verify the document belongs to the organization
    const document = await db.query.documents.findFirst({
      where: and(
        eq(documents.id, documentId),
        eq(documents.organizationId, organizationId)
      ),
    });

    if (!document) {
      throw new Error('Document not found');
    }

    // Safe to delete - user is admin and document belongs to org
    return db.delete(documents).where(eq(documents.id, documentId));
  });
```

**Key principle:** Always filter queries by `organizationId` to ensure users can only access data belonging to their organization. Never trust client-provided IDs without verification.

## Application-level data validation

Never trust client input. Makerkit uses Zod schemas with the function middleware to validate all data entering the system through server functions.

The `.validator(schema)` method automatically validates input against your schema before your handler executes:

```typescript
import { createServerFn } from '@tanstack/react-start';
import { authFunctionMiddleware } from '@kit/function-middleware/functions';
import { z } from 'zod';

const CreateDocumentSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string(),
  organizationId: z.string().uuid(),
});

export const createDocument = createServerFn({ method: 'POST' })
  .middleware(authFunctionMiddleware)
  .validator(CreateDocumentSchema)
  .handler(async ({ data, context }) => {
    // `data` is already validated against CreateDocumentSchema
    // `context.user` is the authenticated user
    return insertDocument(data);
  });
```

{% link href="./data-validation" title="Data Validation" description="Learn how to validate cookies, URL parameters, and form data." /%}

## Transport security: CSRF and response headers

TanStack Start request middleware in `apps/web/src/start.ts` protects every request. The kit declares its own global middleware via `createStart`, which opts out of the framework default and registers protection explicitly:

```typescript
import {
  createCsrfMiddleware,
  createMiddleware,
  createStart,
} from '@tanstack/react-start';
import { setResponseHeader } from '@tanstack/react-start/server';

// CSRF protection scoped to server functions
const csrfMiddleware = createCsrfMiddleware({
  filter: (ctx) => ctx.handlerType === 'serverFn',
});

// Security headers on every response
const securityHeadersMiddleware = createMiddleware({ type: 'request' }).server(
  async ({ next }) => {
    const result = await next();

    setResponseHeader('X-Frame-Options', 'DENY');
    setResponseHeader('X-Content-Type-Options', 'nosniff');

    return result;
  },
);

export const startInstance = createStart(() => ({
  requestMiddleware: [csrfMiddleware, securityHeadersMiddleware],
}));
```

What this gives you:

- **CSRF protection** scoped to server functions (`createServerFn`). The auth catch-all (`/api/auth/*`) and billing webhooks are intentionally excluded because they are hit by external origins (OAuth callbacks, Stripe/Polar); Better Auth enforces its own trusted-origin and signature checks there.
- **`X-Frame-Options: DENY`** to prevent clickjacking.
- **`X-Content-Type-Options: nosniff`** to prevent MIME sniffing.

If you need a stricter transport posture (for example, a Content Security Policy), add the corresponding header inside `securityHeadersMiddleware` and verify it against your third-party origins.

## Multi-Factor Authentication

Makerkit supports TOTP-based multi-factor authentication through Better Auth. When users enable MFA, they must complete the second factor to access their account.

Better Auth handles MFA verification at the authentication layer, ensuring users cannot bypass the second factor requirement.

To enable MFA for your application, ensure the TOTP plugin is configured in your Better Auth setup. Users can then enable MFA from their account settings.

## Role-based permissions

Makerkit includes a hierarchical role system with three default roles:

| Role | Hierarchy Level | Typical Permissions |
|------|-----------------|---------------------|
| Owner | 100 (highest) | Full account control, billing, delete account |
| Admin | 50 | Manage members, settings, most operations |
| Member | 10 | Read access, limited write operations |

Higher numbers denote higher privilege (Owner > Admin > Member).

Permissions are granted to roles, and you can check them by layering the middleware:

```typescript
import { createServerFn } from '@tanstack/react-start';
import { organizationFunctionMiddleware } from '@kit/function-middleware/functions';
import { withMinRole, withFeaturePermission } from '@kit/function-middleware/server';

// Role-based check
export const adminFunction = createServerFn({ method: 'POST' })
  .middleware([...organizationFunctionMiddleware, withMinRole('admin')])
  .handler(async ({ context }) => {
    // Only admins and owners
  });

// Permission-based check
export const deleteFunction = createServerFn({ method: 'POST' })
  .middleware([...organizationFunctionMiddleware, withFeaturePermission({ documents: ['delete'] })])
  .handler(async ({ context }) => {
    // Only users with documents:delete permission
  });
```

## Security best practices

Follow these practices to maintain a secure application:

### Server-side data handling

1. **Never return secrets from server functions or loaders** — API keys and tokens must stay server-side; only their return value crosses to the client
2. **Keep server-only code in `*.server.ts` modules** — the `server-leak-guard` build check prevents the server graph from bundling to the client
3. **Separate client and server exports** — Use different entry points in package.json

```typescript
// packages/my-feature/package.json
{
  "exports": {
    "./server": "./src/server/index.ts",
    "./client": "./src/client/index.tsx"
  }
}
```

### Environment variables

1. **Never prefix secrets with `VITE_`** — These are embedded in client bundles
2. **Use `.env.local` for secrets** — This file is gitignored by default
3. **Validate environment variables at startup** — Fail fast if required variables are missing (see `apps/web/src/config/app.config.ts`)

### Authorization checks

1. **Always verify permissions in data access functions** — Never assume the caller is authorized
2. **Check organization membership before returning data** — Verify the user belongs to the organization
3. **Use the principle of least privilege** — Grant only the permissions necessary

{% link href="./application-best-practices" title="Application Best Practices" description="Learn how to properly handle environment variables and prevent data leaks." /%}

## Security checklist

Before going to production, verify:

- [ ] All server functions apply `authFunctionMiddleware` / `organizationFunctionMiddleware` with `.validator()` schema validation
- [ ] Authorization middleware (`withMinRole`, `withFeaturePermission`) layered where needed
- [ ] Authorization checks in all data-access functions
- [ ] Organization membership verified before returning team data
- [ ] Environment variables with secrets are not prefixed with `VITE_`
- [ ] CSRF middleware and security headers are in place (`apps/web/src/start.ts`)
- [ ] MFA is available for users handling sensitive data

## Next steps

Dive deeper into each security topic:

- [Data Validation](./data-validation) — Validate all user input
- [Application Best Practices](./application-best-practices) — Prevent data leaks
