Security

July 30, 2026 · View on GitHub

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."}>

<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>
.sec-layers {
  display: flex;
  flex-direction: column;
  gap: 12px;
}
.sec-layers .diagram-card {
  display: flex;
  flex-direction: column;
  gap: 6px;
  padding: 14px 16px;
}

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.
  • SQL injectiondb-query/db-exec and Drizzle always parameterize. See SQL Injection Prevention.
  • XSS — React auto-escapes, TipTap and react-markdown sanitize. See XSS Prevention.
  • Auth & CSRF — every defineAction is auth-guarded; cookies are httpOnly + SameSite=lax. See Authentication.
  • Secret encryption — credentials and the vault are encrypted at rest. See Secrets Management.

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(), and route Drizzle reads/writes through the access guards.
  • B. Validate and route external input. Give every action a Zod schema:, and send any server-side fetch of a user/agent URL through the SSRF guard.

Get those two right and the rest is defaults. The 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:

VulnerabilityFramework Protection
SQL injectionParameterized queries in db-query/db-exec and Drizzle ORM
XSSReact auto-escapes JSX; TipTap sanitizes rich text
Data leaksSQL-level scoping via temporary views (owner_email, org_id)
Auth bypassAuth guard auto-protects all defineAction endpoints
Input injectionZod schema validation in defineAction
CSRFSameSite=lax + httpOnly cookies
Secret exposure.env gitignored; credentials & vault encrypted at rest (AES-256-GCM)
SSRFssrfSafeFetch 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:

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:

// 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}')`);

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.

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:

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 and 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."}>

<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>
.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;
}

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:

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:

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:

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

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 typeWhere to store
Deploy-level keys (one per app).env file (gitignored, server-side only)
Per-user / per-org API keyssaveCredential / resolveCredential (encrypted at rest)
Registered secrets (sidebar vault)app_secrets vault (encrypted at rest)
OAuth tokens (Google, GitHub)oauth_tokens store via saveOAuthTokens()
Session tokensAutomatic (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_KEYBETTER_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) prefers WORKSPACE_SECRETS_ENCRYPTION_KEY, then the legacy combined SECRETS_ENCRYPTION_KEY, then material derived from A2A_SECRET, with BETTER_AUTH_SECRET and the app-scoped key retained as compatibility candidates. Successful fallback reads compare-and-swap re-encrypt the shared column under the preferred key. During a workspace-key rotation, set WORKSPACE_SECRETS_ENCRYPTION_KEY_PREVIOUS to the old value until reads have migrated existing rows. 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 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:

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 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
  • A2A_SECRET set on every app that calls or receives A2A traffic — see A2A Identity Verification
  • WORKSPACE_SECRETS_ENCRYPTION_KEY set consistently for multi-app shared vaults; app-local encryption still has SECRETS_ENCRYPTION_KEY / BETTER_AUTH_SECRET fallbacks — see Secrets Management
  • 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 and Messaging 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
  • Ownable-table reads/writes go through the access guards
  • All actions use defineAction with Zod schema: — see Input Validation
  • Server-side fetches of user/agent URLs go through ssrfSafeFetch — see 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
  • 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.


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:

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:

AGENT_NATIVE_KEYS_WORKSPACE_FALLBACK=1

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

What's next

  • Authentication — auth modes, cookie realms, and session details this page builds on
  • Multi-Tenancy — the org-membership model behind org_id scoping
  • Organizations, Teams & Permissions — the org-role vs. resource-role RBAC distinction and the org REST route table
  • Sharing — the ownableColumns() and access-guard pattern applied to shareable resources
  • Messaging — per-integration webhook signing secrets referenced in the checklist above
  • Deployment — the production environment variables this checklist assumes are set