Secure coding practices for agent-native apps: input validation, SQL injection, XSS, secrets, data scoping, and auth. Use when writing any action, route, or component that touches user data or external input.
Scanned 5/27/2026
Install via CLI
openskills install BuilderIO/agent-native---
name: security
description: >-
Secure coding practices for agent-native apps: input validation, SQL
injection, XSS, secrets, data scoping, and auth. Use when writing any action,
route, or component that touches user data or external input.
---
# Security
## Rule
Use the framework's security primitives everywhere. Never bypass them.
## Input Validation
Use `defineAction` with a Zod `schema:` for every action. The framework validates input automatically and returns clear 400 errors for HTTP callers and structured error results for agent tool calls.
```ts
export default defineAction({
schema: z.object({
email: z.string().email(),
role: z.enum(["admin", "member"]),
limit: z.coerce.number().int().min(1).max(100).default(25),
}),
run: async (args) => { /* args is fully typed and validated */ },
});
```
The legacy `parameters:` field (plain JSON Schema) has no runtime validation — do not use it for new code.
## SQL Injection
Never concatenate user input into SQL strings. Use Drizzle ORM's query builder (always safe) or parameterized queries:
```ts
// Safe — Drizzle ORM
await db.select().from(users).where(eq(users.email, args.email));
// Safe — parameterized raw SQL
await client.execute({ sql: "SELECT * FROM users WHERE id = ?", args: [id] });
// NEVER do this
await client.execute(`SELECT * FROM users WHERE id = '${id}'`);
```
## XSS
- React auto-escapes JSX content — trust it.
- Never use `dangerouslySetInnerHTML`, `innerHTML`, `eval()`, or `document.write()` with user-controlled content.
- For rich text editing, use TipTap (framework dependency).
- For rendering markdown, use `react-markdown`.
## Secrets
- OAuth tokens go in the `oauth_tokens` store via `saveOAuthTokens()`.
- Never store secrets in `settings`, `application_state`, source code, or action responses sent to the client.
## User Credentials Are Per-User Data — Never `process.env`
User credentials (API keys, third-party tokens) are per-user (or per-org) data. They MUST live in SQL, scoped per-user (`u:<email>:credential:KEY`) or per-org (`o:<orgId>:credential:KEY`). Always read with the request context:
```ts
import { resolveCredential } from "@agent-native/core/credentials";
const apiKey = await resolveCredential("OPENAI_API_KEY", { userEmail, orgId });
```
On 2026-04-29 the previous one-arg `resolveCredential(key)` form fell back to `process.env[key]` and an unscoped global `settings` row, so every signed-in user inherited the deployment's credentials. Two guards now block this in CI (`pnpm prep`):
- `scripts/guard-no-env-credentials.mjs` — bans `process.env.<KEY>` reads in `packages/core/src/credentials/`, `secrets/`, `vault/`, and `templates/*/server/{lib,routes/api}/credential*` paths, except for an explicit allowlist of deploy-level vars (`DATABASE_URL`, `BETTER_AUTH_SECRET`, `NETLIFY_*`, etc.). Per-line opt-out: `// guard:allow-env-credential — <reason>`.
- `scripts/guard-no-unscoped-credentials.mjs` — bans one-arg calls to `resolveCredential` / `hasCredential` / `saveCredential` / `deleteCredential`. Per-line opt-out: `// guard:allow-unscoped-credential — <reason>`.
If a deploy-level value genuinely needs an env var (CI-set token, host secret), it's not a user credential — keep it out of the credentials/ secrets/ vault/ paths and the env-credentials guard won't see it.
## Guards
Two more CI guards (also wired into `pnpm prep`) target the 2026-04 cross-tenant leak class — request-state escaping into shared process state, and dev-mode sentinel identities used as production fallbacks.
- `scripts/guard-no-env-mutation.mjs` — bans `process.env.<KEY> = …` (and bracket / compound forms) anywhere in production code. On serverless, every warm container handles many concurrent requests in one Node process, so `process.env` mutation leaks across in-flight requests (the "restore" line at the end of a handler races and never helps — most recently the Zoom webhook). Use `runWithRequestContext({ userEmail, orgId, timezone }, fn)` from `@agent-native/core/server` instead — it's AsyncLocalStorage-backed and per-request safe. Allowlisted paths: `scripts/`, `*.spec.ts` / `*.test.ts`, `packages/core/src/dev**`, `templates/*/test/`, anything under `/cli/` or `/scaffold/`. Per-line opt-out: `process.env.X = y // guard:allow-env-mutation — <reason>`.
- `scripts/guard-no-localhost-fallback.mjs` — bans the literal `"local@localhost"` / `'local@localhost'` / `` `local@localhost` `` in production code. The bug class: `getRequestUserEmail() ?? "local@localhost"` silently pools every unauthenticated request into a single shared tenant, leaking credentials, tools, and `application_state` rows between accounts. The right behavior is to throw / 401 when there's no session. Allowlisted paths: the dev-mode auth shim (`packages/core/src/server/auth.ts`), `packages/core/src/dev**`, tests, `scripts/`, `seed/` / `seeds/`, plus a few framework helpers that intentionally inspect or migrate the dev identity. SQL DDL `DEFAULT 'local@localhost'` and the Drizzle helper `.default('local@localhost')` are skipped per-line — schema column defaults are intentional dev fixtures, not the dangerous fallback pattern. Per-line opt-out: `email ?? "local@localhost" // guard:allow-localhost-fallback — <reason>`.
## Auth
- All actions are protected by the auth guard automatically.
- If you must create custom `/api/` routes, always call `getSession(event)` and reject requests without a session:
```ts
import { getSession } from "@agent-native/core/server";
export default defineEventHandler(async (event) => {
const session = await getSession(event);
if (!session) throw createError({ statusCode: 401 });
// ...
});
```
- Never create unprotected routes that modify data.
## Custom HTTP Routes Must Apply Access Control Themselves
This is the single most-failed rule in the codebase. Auto-mounted action routes (`/_agent-native/actions/...`) get a request context wired up automatically. **Hand-written `/api/*` Nitro routes do not.** If your handler queries an ownable resource (any table with `...ownableColumns()`), you MUST:
1. Read the session: `const session = await getSession(event).catch(() => null)`.
2. Run the work inside `runWithRequestContext({ userEmail: session?.email, orgId: session?.orgId }, fn)` from `@agent-native/core/server`.
3. Inside `fn`, query through one of:
- `accessFilter(table, sharesTable)` in the WHERE clause for list/read-many.
- `resolveAccess("<type>", id)` for read-by-id (returns null if no access — return 404, not 403, so existence isn't leaked).
- `assertAccess("<type>", id, "viewer"|"editor"|"admin")` for write/delete-by-id.
```ts
// Bad — Brent's signup leaked every other user's decks because of this exact shape.
export default defineEventHandler(async () => {
const db = getDb();
return db.select().from(schema.decks); // no access filter!
});
// Good
import { getSession, runWithRequestContext } from "@agent-native/core/server";
import { accessFilter } from "@agent-native/core/sharing";
export default defineEventHandler(async (event) => {
const session = await getSession(event).catch(() => null);
return runWithRequestContext(
{ userEmail: session?.email, orgId: session?.orgId },
async () => {
const db = getDb();
return db
.select()
.from(schema.decks)
.where(accessFilter(schema.decks, schema.deckShares));
},
);
});
```
`scripts/guard-no-unscoped-queries.mjs` runs in `pnpm prep` and fails the build if any file in `templates/*/server/`, `templates/*/actions/`, or `packages/*/src/` queries an ownable table without one of the access helpers. Last-resort opt-out is the marker comment `// guard:allow-unscoped — <reason>` — only use it for cases like the sharing primitives themselves or share-token-public viewer endpoints, and always include a reviewer-readable reason.
## Data Scoping
In production, the framework automatically restricts all agent SQL queries to the current user's data using temporary views. This is enforced at the SQL level — the agent cannot bypass it.
### Per-User Scoping (`owner_email`)
Every template table with user data **must** have an `owner_email` text column:
1. Framework detects `owner_email` via schema introspection
2. Creates temp views `WHERE owner_email = <current user>` before each query
3. Auto-injects `owner_email` into INSERT statements
The current user is resolved from `AGENT_USER_EMAIL` (set automatically from the session).
### Per-Org Scoping (`org_id`)
For multi-org apps, tables also need `org_id`:
1. `WHERE org_id = <current org>` is added (in addition to `owner_email` if present)
2. `org_id` is auto-injected into INSERT statements
Enable org scoping in the agent-chat plugin:
```ts
createAgentChatPlugin({
resolveOrgId: async (event) => {
const ctx = await getOrgContext(event);
return ctx.orgId;
},
});
```
### Column Conventions
| Column | Purpose | Required |
| ------------- | ----------------------- | ------------------------------- |
| `owner_email` | Per-user data isolation | Yes, for all user-facing tables |
| `org_id` | Per-org data isolation | Yes, for multi-org apps |
Run `pnpm action db-check-scoping` to verify. Use `--require-org` for multi-org apps.
## Checklist
- [ ] New action uses `defineAction` with a Zod `schema:`
- [ ] No SQL string concatenation with user input
- [ ] No `dangerouslySetInnerHTML` with user content
- [ ] New env vars in `.env` only, not committed
- [ ] New user-data tables have `owner_email` column
- [ ] Custom routes call `getSession` and reject unauthenticated requests
## Related Skills
- `storing-data` — SQL patterns and the agent's db tools
- `actions` — `defineAction` with Zod schema validation
- `authentication` — Auth modes, sessions, and org context
No comments yet. Be the first to comment!