---
title: "Security"
description: "Security model for agent-native apps: input validation, SQL injection prevention, XSS, data scoping, secrets management, and auth patterns."
---

# Security

Agent-native apps are designed to be secure by default. The framework provides automatic protections at multiple layers — you get SQL-level data isolation, parameterized queries, input validation, and authentication out of the box.

## What you get for free, and what you own {#what-you-own}

<Diagram id="doc-block-pjg4xe" title="Defense in layers" summary={"The framework owns most of the threat surface; you own two things — tagging tables for scoping and validating external input."}>

```html
<div class="sec-layers">
  <div class="diagram-card free">
    <span class="diagram-pill ok">Framework owns</span
    ><small class="diagram-muted"
      >SQL isolation &middot; parameterized queries &middot; XSS escaping
      &middot; auth guard &middot; CSRF cookies &middot; secret
      encryption</small
    >
  </div>
  <div class="diagram-card you">
    <span class="diagram-pill warn">You own</span
    ><small class="diagram-muted"
      >A. tag tables with ownableColumns() &amp; route through access guards<br />B.
      give every action a Zod schema &amp; send user URLs through the SSRF
      guard</small
    >
  </div>
</div>
```

```css
.sec-layers {
  display: flex;
  flex-direction: column;
  gap: 12px;
}
.sec-layers .diagram-card {
  display: flex;
  flex-direction: column;
  gap: 6px;
  padding: 14px 16px;
}
```

</Diagram>

When you build on the standard patterns, the framework already handles most of the threat surface for you:

- **Data isolation** — agent SQL is rewritten so it can only see the current user's (and active org's) rows. See [Data Scoping](#data-scoping).
- **SQL injection** — `db-query`/`db-exec` and Drizzle always parameterize. See [SQL Injection Prevention](#sql-injection).
- **XSS** — React auto-escapes, TipTap and `react-markdown` sanitize. See [XSS Prevention](#xss).
- **Auth & CSRF** — every `defineAction` is auth-guarded; cookies are `httpOnly` + `SameSite=lax`. See [Authentication](#auth).
- **Secret encryption** — credentials and the vault are encrypted at rest. See [Secrets Management](#secrets).

That leaves a small surface you actually have to think about:

- **A. Tag your tables for scoping.** Add `owner_email` (and `org_id` for team data) via [`ownableColumns()`](#data-scoping), and route Drizzle reads/writes through the [access guards](#access-guards).
- **B. Validate and route external input.** Give every action a Zod [`schema:`](#input-validation), and send any server-side fetch of a user/agent URL through the [SSRF guard](#ssrf).

Get those two right and the rest is defaults. The [Production Checklist](#production-checklist) is the one-page confirmation before you ship.

## Security by Design {#secure-by-design}

The framework architecture prevents common vulnerabilities when you use the standard patterns:

| Vulnerability   | Framework Protection                                                   |
| --------------- | ---------------------------------------------------------------------- |
| SQL injection   | Parameterized queries in `db-query`/`db-exec` and Drizzle ORM          |
| XSS             | React auto-escapes JSX; TipTap sanitizes rich text                     |
| Data leaks      | SQL-level scoping via temporary views (`owner_email`, `org_id`)        |
| Auth bypass     | Auth guard auto-protects all `defineAction` endpoints                  |
| Input injection | Zod schema validation in `defineAction`                                |
| CSRF            | `SameSite=lax` + `httpOnly` cookies                                    |
| Secret exposure | `.env` gitignored; credentials & vault encrypted at rest (AES-256-GCM) |
| SSRF            | `ssrfSafeFetch` blocks internal/metadata targets + redirect rebinding  |

## Input Validation {#input-validation}

Use `defineAction` with a Zod `schema:` for every action. The framework validates input automatically before your code runs:

```ts
import { z } from "zod";
import { defineAction } from "@agent-native/core/action";

export default defineAction({
  description: "Create a note",
  schema: z.object({
    title: z.string().min(1).max(200).describe("Note title"),
    content: z.string().optional().describe("Note body"),
  }),
  run: async (args) => {
    // args is guaranteed valid — invalid input never reaches here
  },
});
```

Invalid input returns clear error messages (400 for HTTP, structured error for agent calls). The legacy `parameters:` format provides no runtime validation.

## SQL Injection Prevention {#sql-injection}

The framework's `db-query` and `db-exec` tools use parameterized queries. User input is passed as arguments, never interpolated into the SQL string:

```ts
// SAFE — parameterized query (framework default)
await exec({ sql: "INSERT INTO notes (title) VALUES (?)", args: [title] });

// SAFE — Drizzle ORM (always generates parameterized queries)
await db.insert(notes).values({ title, ownerEmail: email });

// DANGEROUS — string concatenation (never do this)
await exec(`INSERT INTO notes (title) VALUES ('${title}')`);
```

<Callout id="doc-block-7su5ts" tone="risk">

Never build SQL by string concatenation or template literals. Pass user input as `args` to `exec` / `db-query`, or use Drizzle — both always parameterize. The `pnpm guards` checks catch unscoped and concatenated queries at CI time.

</Callout>

## XSS Prevention {#xss}

React auto-escapes all JSX expressions. Additional guidelines:

- Never use `dangerouslySetInnerHTML` with user-controlled content
- Never use `innerHTML`, `eval()`, or `document.write()`
- For rich text editing, use TipTap (framework dependency) — it sanitizes through its schema
- For rendering markdown, use `react-markdown` — it converts to React elements safely

## Server-Side Fetch (SSRF) {#ssrf}

Any server-side `fetch` of a user- or agent-controlled URL must go through the framework SSRF guard, or it can be pointed at cloud metadata (`169.254.169.254`), `localhost`, or internal services:

```ts
import { ssrfSafeFetch } from "@agent-native/core/extensions/url-safety";

const res = await ssrfSafeFetch(userProvidedUrl, {}, { maxRedirects: 3 });
```

`ssrfSafeFetch` blocks private/internal targets, re-checks the resolved IP at connect time (DNS rebinding), and re-validates every redirect hop so a public URL can't redirect into the private network. The extension iframe proxy, `upload-image`, and the design-token importer all route through it. For a pre-flight-only check, use `isBlockedExtensionUrlWithDns(url)` with `redirect: "manual"`.

## Data Scoping {#data-scoping}

In production, the framework automatically restricts agent SQL queries to the current user's data. This is enforced at the SQL level — agents cannot bypass it. This section is the canonical reference for the scoping pipeline; the [Authentication](/docs/authentication) and [Multi-Tenancy](/docs/multi-tenancy) docs link here for the mechanics.

### The scoping pipeline {#scoping-pipeline}

Scoping flows from the authenticated session down to the SQL the agent runs:

```
session.orgId → AGENT_ORG_ID → SQL row scoping
```

<Diagram id="doc-block-grba11" title="The scoping pipeline" summary={"Agent SQL never touches base tables directly — it reads through a temporary view scoped to the current identity, so a bare table name can only return owned rows."}>

```html
<div class="scope-pipe">
  <div class="diagram-node">
    Signed-in session<br /><small class="diagram-muted"
      >email &middot; orgId</small
    >
  </div>
  <div class="diagram-arrow diagram-muted" aria-hidden="true">&rarr;</div>
  <div class="diagram-node">
    Request context<br /><small class="diagram-muted">AGENT_ORG_ID</small>
  </div>
  <div class="diagram-arrow diagram-muted" aria-hidden="true">&rarr;</div>
  <div class="diagram-box">
    Temporary VIEW<br /><small class="diagram-muted"
      >WHERE owner_email = ? AND org_id = ?</small
    >
  </div>
  <div class="diagram-arrow diagram-muted" aria-hidden="true">&rarr;</div>
  <div class="diagram-node ok">
    Agent SQL<br /><small class="diagram-muted">bare table names only</small>
  </div>
</div>
```

```css
.scope-pipe {
  display: flex;
  align-items: center;
  gap: 12px;
  flex-wrap: wrap;
}
.scope-pipe .diagram-node {
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: 10px 14px;
}
.scope-pipe .diagram-arrow {
  font-size: 22px;
  line-height: 1;
}
```

</Diagram>

The signed-in session carries `email` and (when an org is active) `orgId`. The framework establishes request context from that session, exposes the active org to agent SQL as `AGENT_ORG_ID`, and rewrites every query so it can only see rows the current identity owns. The same path applies whether the query comes from the UI, an action, or the agent — the agent cannot read data for an org the user isn't a member of.

### Per-User Scoping (`owner_email`)

Every table with user-specific data **must** have an `owner_email` text column. Use the camelCase Drizzle property name — `accessFilter` reads `resourceTable.ownerEmail`:

```ts
import {
  table,
  text,
  integer,
  ownableColumns,
} from "@agent-native/core/db/schema";

// Minimal: just the owner column
export const notes = table("notes", {
  id: text("id").primaryKey(),
  title: text("title").notNull(),
  content: text("content"),
  ownerEmail: text("owner_email").notNull(), // REQUIRED — camelCase property
});

// Or use ownableColumns() to add owner_email + org_id + visibility in one call
export const notes = table("notes", {
  id: text("id").primaryKey(),
  title: text("title").notNull(),
  content: text("content"),
  ...ownableColumns(),
});
```

The framework creates temporary SQL views that filter queries automatically:

```sql
CREATE TEMPORARY VIEW "notes" AS
  SELECT * FROM main."notes"
  WHERE "owner_email" = 'alice@example.com';
```

INSERT statements get `owner_email` auto-injected when the column isn't already present.

The `db-query` / `db-exec` tools reject schema-qualified table references (`public.<table>`, `main.<table>`) — a qualified name resolves to the base table and would bypass the temporary view above. Agents use bare table names; scoping is applied automatically.

### Per-Org Scoping (`org_id`)

For multi-user apps where teams share data, add an `org_id` column. When both columns are present, queries are scoped by both: `WHERE owner_email = ? AND org_id = ?`.

The `ownableColumns()` schema helper adds `owner_email`, `org_id`, and `visibility` in one call, so new tenant-aware tables get the full scoping contract by default:

```ts
import { table, text, ownableColumns } from "@agent-native/core/db/schema";

export const projects = table("projects", {
  id: text("id").primaryKey(),
  title: text("title").notNull(),
  ...ownableColumns(), // adds owner_email + org_id + visibility
});
```

<DataModel
  id="doc-block-1ifdlq7"
  title="What ownableColumns() adds"
  summary="The three columns that make a table tenant-aware and shareable."
  entities={[
    {
      id: "ownable",
      name: "ownable resource",
      note: "Any table that spreads ...ownableColumns()",
      fields: [
        {
          name: "owner_email",
          type: "text",
          nullable: false,
          note: "Creator. Auto-filled by write actions; auto-injected on INSERT.",
        },
        {
          name: "org_id",
          type: "text",
          nullable: true,
          note: "Owner's active org at creation. Drives org-visibility checks.",
        },
        {
          name: "visibility",
          type: "enum",
          nullable: false,
          note: "private | org | public — coarse default, defaults to private.",
        },
      ],
    },
  ]}
/>

### Access guards in actions {#access-guards}

Raw agent SQL is scoped by the temporary views above. Action code that queries with Drizzle directly should go through the framework's access helpers so reads and writes stay scoped to the current identity:

- **`accessFilter`** — returns the `WHERE` predicate that limits a query to rows the current user/org may see. Use it in list/read queries.
- **`resolveAccess`** — resolves the effective access scope (owner, org, shared) for the current request.
- **`assertAccess`** — guards a write or single-record read, throwing if the current identity may not act on the target row.

Tables built with `ownableColumns()` require these scoped reads and writes; custom Nitro routes must establish request context before querying ownable data. The `guard-no-unscoped-queries` check (run via `pnpm guards`) enforces this at CI time. See the `sharing` skill for the full helper API.

### Validation

```bash
pnpm action db-check-scoping           # Check all tables have owner_email
pnpm action db-check-scoping --require-org  # Also require org_id
```

## Secrets Management {#secrets}

| Secret type                        | Where to store                                             |
| ---------------------------------- | ---------------------------------------------------------- |
| Deploy-level keys (one per app)    | `.env` file (gitignored, server-side only)                 |
| Per-user / per-org API keys        | `saveCredential` / `resolveCredential` (encrypted at rest) |
| Registered secrets (sidebar vault) | `app_secrets` vault (encrypted at rest)                    |
| OAuth tokens (Google, GitHub)      | `oauth_tokens` store via `saveOAuthTokens()`               |
| Session tokens                     | Automatic (Better Auth handles this)                       |

Per-user/per-org credentials and the vault are encrypted at rest with AES-256-GCM. For a per-user/per-org credential, the key precedence is `<APP_NAME>_SECRETS_ENCRYPTION_KEY` (app-scoped — useful in a multi-app workspace where apps shouldn't share one key) → `SECRETS_ENCRYPTION_KEY` → `BETTER_AUTH_SECRET`; production refuses to start without one of these set. The shared workspace vault (`app_secrets`, meant to decrypt the same in sibling apps) checks `SECRETS_ENCRYPTION_KEY` / `BETTER_AUTH_SECRET` first, then on a hosted workspace deploy falls back further to material derived from `A2A_SECRET`, and only then to the app-scoped key as a last resort. To encrypt any pre-existing plaintext credential rows in place, run `pnpm action db-migrate-encrypt-credentials` (idempotent, non-destructive).

Never store secrets in `settings`, `application_state`, source code, or action responses. Use the credential / vault APIs above — they handle both encryption and per-user scoping.

## Authentication {#auth}

Auth is automatic. See the [Authentication](/docs/authentication) docs for the full setup.

**Key points for security:**

- `defineAction` endpoints are auto-protected by the auth guard
- Custom `/api/` routes must call `getSession(event)` and check the result
- State-changing operations should use POST (the default for actions)
- `SameSite=lax` + `httpOnly` cookies prevent most CSRF attacks

## A2A Identity Verification {#a2a-identity}

When apps call each other via the A2A protocol, they verify identity using JWT tokens signed with a shared secret:

```bash
A2A_SECRET=your-shared-secret-at-least-32-chars
```

1. App A signs a JWT containing `sub: "steve@example.com"`
2. App B verifies the JWT signature with the same secret
3. App B reads the verified `sub` claim into request context
4. Data scoping applies — App B only shows Steve's data

Without `A2A_SECRET` in production, every A2A endpoint and the `/_agent-native/integrations/process-task` self-fire endpoint return **503**. Set it on every app that calls or receives A2A traffic. (For local development the framework still allows unauthenticated calls.)

## Inbound Webhooks {#webhooks}

Inbound webhook handlers (Resend, SendGrid, Slack, Telegram, WhatsApp) refuse forged requests by default in production: when the corresponding signing secret env var is missing, the handler returns 401 instead of accepting and dispatching.

This was previously a "warn and accept" stance — set the secret you'd otherwise be missing, or opt back into the old behavior with `AGENT_NATIVE_ALLOW_UNVERIFIED_WEBHOOKS=1` for local dev only. See [Messaging](/docs/messaging#env-vars) for the per-integration signing-secret variables.

Google Docs Pub/Sub push notifications don't go through that generic adapter gate — they're a separate code path that verifies an OIDC bearer token against `GOOGLE_DOCS_PUSH_AUDIENCE`. When that audience variable is unset in production, the endpoint fails closed with **503** (not 401); when it's set but the token doesn't verify, it returns 401.

## Production Checklist {#production-checklist}

### Auth & secrets

- [ ] `BETTER_AUTH_SECRET` set to a random 32+ char string (`openssl rand -hex 32`), unless this is a hosted workspace deploy deriving it from `A2A_SECRET`
- [ ] `OAUTH_STATE_SECRET` set to a separate random 32+ char string (don't reuse `BETTER_AUTH_SECRET`) — see [OAuth State Signing](#oauth-state)
- [ ] `A2A_SECRET` set on every app that calls or receives A2A traffic — see [A2A Identity Verification](#a2a-identity)
- [ ] `SECRETS_ENCRYPTION_KEY` set (or rely on the `BETTER_AUTH_SECRET` fallback) — see [Secrets Management](#secrets)
- [ ] `AUTH_SKIP_EMAIL_VERIFICATION` is **not** set in production (or set only on QA preview deploys)

### Webhook secrets (set the ones for integrations you use)

- [ ] Signing secret set for each enabled inbound integration — see [Inbound Webhooks](#webhooks) and [Messaging](/docs/messaging#env-vars) for the per-integration list
- [ ] `AGENT_NATIVE_ALLOW_UNVERIFIED_WEBHOOKS` is **not** set in prod

### Schema

- [ ] Every user-facing table has `owner_email`, multi-user tables also `org_id` — see [Data Scoping](#data-scoping)
- [ ] Ownable-table reads/writes go through the [access guards](#access-guards)
- [ ] All actions use `defineAction` with Zod `schema:` — see [Input Validation](#input-validation)
- [ ] Server-side fetches of user/agent URLs go through `ssrfSafeFetch` — see [SSRF](#ssrf)
- [ ] No `dangerouslySetInnerHTML` with user content (or output is run through DOMPurify)
- [ ] No string-concatenated SQL
- [ ] `pnpm guards` is clean (`guard-no-unscoped-queries`, `guard-no-env-credentials`, `guard-no-env-mutation`, `guard-no-localhost-fallback`, `guard-no-unscoped-credentials`, `guard-no-drizzle-push`)
- [ ] Tested with two user accounts to verify data isolation

### Misc hardening

- [ ] `AGENT_NATIVE_DEBUG_ERRORS` is **not** set in real prod (only on debug previews)
- [ ] `AGENT_NATIVE_KEYS_WORKSPACE_FALLBACK` is **not** set unless your org actually shares workspace keys — see [Cross-User Tooling Secrets](#tooling-secrets)
- [ ] In multi-tenant deployments, **users bring their own `ANTHROPIC_API_KEY`** — the framework refuses to fall back to the deploy-level env var

Run `agent-native doctor` (`pnpm doctor` in a scaffolded app) to check the schema, credential, and environment-variable items on this checklist automatically — every finding names the guard, file, and line. See the [Doctor](/docs/doctor) docs.

---

The sections below cover niche environment flags you only reach for in specific deployments. Most apps never touch them.

## OAuth State Signing {#oauth-state}

OAuth flows (Google, Atlassian, Zoom) sign their state envelope with a dedicated HMAC key:

```bash
OAUTH_STATE_SECRET=$(openssl rand -hex 32)
```

This used to fall back to `GOOGLE_CLIENT_SECRET` (a credential shared with Google) — a leak of the Google secret would have let attackers forge OAuth state envelopes. The dedicated key is independent of any third-party secret. If `OAUTH_STATE_SECRET` is unset, the framework falls back to `BETTER_AUTH_SECRET`; hosted workspace deploys can also derive a per-purpose OAuth key from the already-required `A2A_SECRET`. If none of those server secrets are available, OAuth flows fail in production.

`redirect_uri` query parameters are also validated against an allowlist (same-origin + framework `/_agent-native/...` paths). Custom OAuth flows in templates should use the framework's `isAllowedOAuthRedirectUri()` helper before signing state.

## Cross-User Tooling Secrets {#tooling-secrets}

Tools and automations that reference `${keys.NAME}` resolve secrets per-user by default. Workspace-scope fallback is **off by default** in this version — a malicious org member could otherwise plant a workspace `OPENAI_API_KEY` and harvest other members' API calls.

If your org genuinely shares workspace-wide keys (e.g. a single corporate Stripe key), opt back into the old behavior with:

```bash
AGENT_NATIVE_KEYS_WORKSPACE_FALLBACK=1
```

Workspace-scope secret writes still require org owner/admin role regardless of this flag.

## What's next

- [**Authentication**](/docs/authentication) — auth modes, cookie realms, and session details this page builds on
- [**Multi-Tenancy**](/docs/multi-tenancy) — the org-membership model behind `org_id` scoping
- [**Organizations, Teams & Permissions**](/docs/organizations-teams-permissions) — the org-role vs. resource-role RBAC distinction and the org REST route table
- [**Sharing**](/docs/sharing) — the `ownableColumns()` and access-guard pattern applied to shareable resources
- [**Messaging**](/docs/messaging#env-vars) — per-integration webhook signing secrets referenced in the checklist above
- [**Deployment**](/docs/deployment) — the production environment variables this checklist assumes are set
