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.
Security 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 files gitignored; OAuth tokens in dedicated store |
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";
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
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}')`);XSS Prevention
React auto-escapes all JSX expressions. Additional guidelines:
- Never use
dangerouslySetInnerHTMLwith user-controlled content - Never use
innerHTML,eval(), ordocument.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
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.
Per-User Scoping (owner_email)
Every table with user-specific data must have an owner_email text column:
import { table, text, integer } from "@agent-native/core/db/schema";
export const notes = table("notes", {
id: text("id").primaryKey(),
title: text("title").notNull(),
content: text("content"),
owner_email: text("owner_email").notNull(), // REQUIRED
});The framework creates temporary SQL views that filter queries automatically:
CREATE TEMPORARY VIEW "notes" AS
SELECT * FROM main."notes"
WHERE "owner_email" = '[email protected]';INSERT statements get owner_email auto-injected when the column isn't already present.
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 = ?.
Validation
pnpm action db-check-scoping # Check all tables have owner_email
pnpm action db-check-scoping --require-org # Also require org_idSecrets Management
| Secret type | Where to store |
|---|---|
| API keys (OpenAI, Stripe, etc.) | .env file (gitignored, server-side only) |
| OAuth tokens (Google, GitHub) | oauth_tokens store via saveOAuthTokens() |
| Session tokens | Automatic (Better Auth handles this) |
Never store secrets in settings, application_state, source code, or action responses.
Authentication
Auth is automatic. See the Authentication docs for the full setup.
Key points for security:
defineActionendpoints are auto-protected by the auth guard- Custom
/api/routes must callgetSession(event)and check the result - State-changing operations should use POST (the default for actions)
SameSite=lax+httpOnlycookies prevent most CSRF attacks
A2A Identity Verification
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- App A signs a JWT containing
sub: "[email protected]" - App B verifies the JWT signature with the same secret
- App B reads the verified
subclaim into request context - 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
Inbound webhook handlers (Resend, SendGrid, Slack, Telegram, WhatsApp, Recall.ai, Deepgram, Zoom, Google Docs Pub/Sub) 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 deployment.md → Inbound Webhooks for the full env-var list.
OAuth State Signing
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; if both are unset, the 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
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=1Workspace-scope secret writes still require org owner/admin role regardless of this flag.
Production Checklist
Auth & secrets
-
BETTER_AUTH_SECRETset to a random 32+ char string (openssl rand -hex 32) -
OAUTH_STATE_SECRETset to a separate random 32+ char string (don't reuseBETTER_AUTH_SECRET) -
A2A_SECRETset on every app that calls or receives A2A traffic -
SECRETS_ENCRYPTION_KEYset (or rely on theBETTER_AUTH_SECRETfallback) -
AUTH_SKIP_EMAIL_VERIFICATIONis not set in production (or set only on QA preview deploys)
Webhook secrets (set the ones for integrations you use)
-
EMAIL_INBOUND_WEBHOOK_SECRETif Resend / SendGrid inbound is enabled -
SLACK_SIGNING_SECRETif Slack is enabled -
TELEGRAM_WEBHOOK_SECRET/WHATSAPP_APP_SECRETfor those integrations -
RECALL_WEBHOOK_SECRET,DEEPGRAM_WEBHOOK_SECRET,ZOOM_WEBHOOK_SECRETfor calls -
AGENT_NATIVE_ALLOW_UNVERIFIED_WEBHOOKSis not set in prod
Schema
- Every user-facing table has
owner_email - Multi-user tables also have
org_id - All actions use
defineActionwith Zodschema: - No
dangerouslySetInnerHTMLwith user content (or output is run through DOMPurify) - No string-concatenated SQL
-
pnpm guardsis 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_ERRORSis not set in real prod (only on debug previews) -
AGENT_NATIVE_KEYS_WORKSPACE_FALLBACKis not set unless your org actually shares workspace keys - In multi-tenant deployments, users bring their own
ANTHROPIC_API_KEY— the framework refuses to fall back to the deploy-level env var