fortknoxsecurityscanner
A battle-hardened security checklist and pattern library for Convex apps. Goes beyond basic auth checks to cover ownership verification, rate limiting with @convex-dev/ratelimiter, enumeration attack prevention, HTTP action hardening, and internal vs. public function architecture. Built with the latest Convex docs — not assumptions.
Packaged view
This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.
Install command
npx @skill-hub/cli install loren-fortknoxsecurityscanner
Repository
A battle-hardened security checklist and pattern library for Convex apps. Goes beyond basic auth checks to cover ownership verification, rate limiting with @convex-dev/ratelimiter, enumeration attack prevention, HTTP action hardening, and internal vs. public function architecture. Built with the latest Convex docs — not assumptions.
Open repositoryBest for
Primary workflow: Write Technical Docs.
Technical facets: Tech Writer, Security, Testing.
Target audience: everyone.
License: Unknown.
Original source
Catalog source: SkillHub Club.
Repository owner: loren.
This is still a mirrored public skill entry. Review the repository before installing into production workflows.
What it helps with
- Install fortknoxsecurityscanner into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
- Review https://www.skillhub.club/skills/loren-fortknoxsecurityscanner before adding fortknoxsecurityscanner to shared team environments
- Use fortknoxsecurityscanner for security workflows
Works across
Favorites: 0.
Sub-skills: 0.
Aggregator: No.
Original source / Raw SKILL.md
---
name: "fortknoxsecurityscanner"
description: "A battle-hardened security checklist and pattern library for Convex apps. Goes beyond basic auth checks to cover ownership verification, rate limiting with @convex-dev/ratelimiter, enumeration attack prevention, HTTP action hardening, and internal vs. public function architecture. Built with the latest Convex docs — not assumptions."
category: "Security"
---
# fortknoxsecurityscanner
A battle-hardened security checklist and pattern library for Convex apps. Goes beyond basic auth checks to cover ownership verification, rate limiting with @convex-dev/ratelimiter, enumeration attack prevention, HTTP action hardening, and internal vs. public function architecture. Built with the latest Convex docs — not assumptions.
## Instructions
- Describe how this skill should be used
## Examples
```
// example usage
```
---
## Referenced Files
> The following files are included in this skill.
### convex-security-check_1.md
```md
---
name: Fort Knox Security Scanner
description: Comprehensive security audit for Convex applications covering authentication, authorization, function exposure, argument validation, row-level access control, rate limiting, HTTP action hardening, environment variables, and production security. Use when auditing, building, or hardening any Convex backend.
version: 2.0.0
author: Convex
tags: [convex, security, authentication, authorization, rate-limiting, checklist, production]
---
# Fort Knox Security Scanner
A comprehensive security audit for Convex applications. Covers every layer from auth to production hardening.
**Before implementing anything, fetch the latest docs — never assume:**
| Topic | URL |
|---|---|
| Authentication | https://docs.convex.dev/auth |
| Auth in Functions | https://docs.convex.dev/auth/functions-auth |
| Internal Functions | https://docs.convex.dev/functions/internal-functions |
| Argument Validation | https://docs.convex.dev/functions/validation |
| Best Practices | https://docs.convex.dev/understanding/best-practices |
| Production Security | https://docs.convex.dev/production |
| Environment Variables | https://docs.convex.dev/production/environment-variables |
| Authorization Guide | https://stack.convex.dev/authorization |
| Rate Limiting | https://stack.convex.dev/rate-limiting |
| Broader context | https://docs.convex.dev/llms.txt |
---
## Security Mindset
Convex deployment endpoints are **exposed to the open internet**. Every public function (`query`, `mutation`, `action`) can be called by anyone — including malicious actors probing your app. Your backend is your last and most important line of defense. Frontend checks are UX, not security.
**The security hierarchy:**
1. Authenticate: Who is making this request?
2. Authorize: Are they allowed to do this specific thing?
3. Validate: Is the data they sent the shape we expect?
4. Limit: Are they doing this too often?
Every public function should answer all four questions before touching the database.
---
## The Full Security Audit Checklist
### 1. Authentication
- [ ] Auth provider configured (Clerk, Auth0, WorkOS AuthKit, or Convex Auth)
- [ ] Every sensitive query and mutation calls `ctx.auth.getUserIdentity()` and throws if null
- [ ] Unauthenticated public access is **explicitly intentional** and documented where allowed
- [ ] HTTP actions validate the `Authorization` header with a Bearer JWT token
- [ ] Service-to-service calls use a shared secret checked against an environment variable
- [ ] Session tokens are never stored in the database or logged
### 2. Authorization (Row-Level Access Control)
- [ ] Users can only read their own data — verified with `tokenIdentifier` or user `_id`
- [ ] Users can only write/update/delete records they own — ownership checked before every mutation
- [ ] Admin-only functions verify the user's role before proceeding
- [ ] Shared resources (team data, org data) check membership before returning anything
- [ ] Functions are granular: `setTeamOwner` instead of `updateTeam` (enables precise checks)
- [ ] Authorization helpers (`requireAuth`, `requireAdmin`, `isTeamMember`) are shared, not duplicated
- [ ] No function trusts a client-provided user ID — always derive from `ctx.auth`
### 3. Function Exposure
- [ ] Every `query`, `mutation`, `action`, and `httpAction` has been audited
- [ ] Sensitive operations use `internalQuery`, `internalMutation`, `internalAction`
- [ ] No payment, credit, role, or permission logic is in a public function
- [ ] HTTP actions validate both the `Authorization` header and request origin
- [ ] Public functions cannot be used to enumerate other users' data
### 4. Argument Validation
- [ ] Every public function has explicit `args` validators — no exceptions
- [ ] Every public function has explicit `returns` validators
- [ ] `v.any()` is never used for args that touch the database
- [ ] ID validators use `v.id("tableName")` with the correct table name (prevents cross-table ID abuse)
- [ ] String inputs are bounded with length constraints where appropriate
- [ ] Enum-like values use `v.union(v.literal("a"), v.literal("b"))` — not bare `v.string()`
- [ ] Object validators list only allowed fields — no open-ended `v.object({})` for writes
- [ ] ESLint rule `@convex-dev/require-argument-validators` is enabled
### 5. Rate Limiting
- [ ] Expensive mutations (AI calls, emails, file uploads) have per-user rate limits
- [ ] Authentication failure paths (login, password reset) are rate limited per user/IP
- [ ] Free tier signup flows are rate limited globally to deter bots
- [ ] Rate limits are evaluated inside mutations (transactional) — not in actions before calling a mutation
- [ ] `@convex-dev/ratelimiter` component is used for consistent, type-safe limits
- [ ] Rate limit names are centrally defined for type safety and reuse
### 6. Environment Variables
- [ ] All API keys and secrets are in environment variables — never hardcoded
- [ ] `process.env` is only accessed inside `action` functions (not queries or mutations)
- [ ] Dev and production environments use different keys
- [ ] Missing required env vars throw a clear, descriptive error at runtime
- [ ] No secrets appear in schema definitions, function names, or log output
- [ ] `.env` files are in `.gitignore`
### 7. HTTP Actions
- [ ] All HTTP actions validate `Authorization: Bearer <jwt>` header
- [ ] Request body is validated with Zod or equivalent (not just TypeScript types)
- [ ] CORS headers are explicitly set and restrict allowed origins
- [ ] HTTP actions that trigger mutations call them as `internal` functions
- [ ] Response bodies never leak internal error details to the client
### 8. Production Hardening
- [ ] Schema enforcement is on — never turned off in production
- [ ] Function changes are backwards-compatible until old clients have cycled out
- [ ] Scheduled functions handle stale arguments from schema/function changes
- [ ] No `console.log` in production code that outputs sensitive data
- [ ] Dashboard access is restricted to necessary team members
- [ ] `CONVEX_DEPLOY_KEY` is stored securely in CI/CD — not in the repo
---
## Authentication Patterns
### The Auth Helper (Use Everywhere)
```typescript
// convex/lib/auth.ts
import { QueryCtx, MutationCtx } from "./_generated/server";
import { ConvexError } from "convex/values";
/**
* Throws if the caller is not authenticated.
* Use this at the top of every sensitive function.
*/
export async function requireAuth(ctx: QueryCtx | MutationCtx) {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new ConvexError({
code: "UNAUTHENTICATED",
message: "You must be logged in to perform this action.",
});
}
return identity;
}
/**
* Returns the full user record from the database.
* Throws if not authenticated or if the user record doesn't exist.
*/
export async function requireUser(ctx: QueryCtx | MutationCtx) {
const identity = await requireAuth(ctx);
const user = await ctx.db
.query("users")
.withIndex("by_tokenIdentifier", (q) =>
q.eq("tokenIdentifier", identity.tokenIdentifier)
)
.unique();
if (!user) {
throw new ConvexError({
code: "USER_NOT_FOUND",
message: "User profile not found. Please complete onboarding.",
});
}
return user;
}
/**
* Throws unless the authenticated user has the "admin" role.
*/
export async function requireAdmin(ctx: QueryCtx | MutationCtx) {
const user = await requireUser(ctx);
if (user.role !== "admin") {
throw new ConvexError({
code: "FORBIDDEN",
message: "Admin access required.",
});
}
return user;
}
```
### Secure Query Pattern
```typescript
// convex/users.ts
import { query } from "./_generated/server";
import { v } from "convex/values";
import { requireUser } from "./lib/auth";
export const getMyProfile = query({
args: {},
returns: v.object({
_id: v.id("users"),
name: v.string(),
email: v.string(),
role: v.union(v.literal("user"), v.literal("admin")),
}),
handler: async (ctx) => {
// Always derive identity from ctx — never trust a client-provided ID
const user = await requireUser(ctx);
return user;
},
});
```
### HTTP Action Auth Pattern
```typescript
// convex/http.ts
import { httpAction } from "./_generated/server";
import { ConvexError } from "convex/values";
export const webhookHandler = httpAction(async (ctx, request) => {
// Validate Bearer token from Authorization header
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
return new Response("Unauthorized", { status: 401 });
}
// For webhook endpoints using a shared secret instead of JWT:
const secret = process.env.WEBHOOK_SECRET;
const provided = request.headers.get("x-webhook-secret");
if (!secret || provided !== secret) {
return new Response("Forbidden", { status: 403 });
}
// Validate the request body with Zod
let body: unknown;
try {
body = await request.json();
} catch {
return new Response("Invalid JSON", { status: 400 });
}
// ... validate body shape with Zod here ...
// Call an internal mutation — never a public one
await ctx.runMutation(internal.webhooks.process, { body });
return new Response(null, { status: 200 });
});
```
---
## Authorization Patterns
### Ownership Check Before Mutation
```typescript
// convex/tasks.ts
import { mutation } from "./_generated/server";
import { v } from "convex/values";
import { ConvexError } from "convex/values";
import { requireUser } from "./lib/auth";
export const updateTask = mutation({
args: {
taskId: v.id("tasks"),
title: v.string(),
// Only allow specific fields — never spread args.update into db.patch()
},
returns: v.null(),
handler: async (ctx, args) => {
const user = await requireUser(ctx);
const task = await ctx.db.get(args.taskId);
// Verify the task exists and belongs to this user
// Use _id (database ID), not tokenIdentifier, for ownership comparisons
if (!task || task.userId !== user._id) {
// Return the same error for "not found" and "not authorized"
// This prevents enumeration attacks
throw new ConvexError({
code: "NOT_FOUND",
message: "Task not found.",
});
}
await ctx.db.patch(args.taskId, { title: args.title });
return null;
},
});
export const deleteTask = mutation({
args: {
taskId: v.id("tasks"),
},
returns: v.null(),
handler: async (ctx, args) => {
const user = await requireUser(ctx);
const task = await ctx.db.get(args.taskId);
if (!task || task.userId !== user._id) {
throw new ConvexError({ code: "NOT_FOUND", message: "Task not found." });
}
await ctx.db.delete(args.taskId);
return null;
},
});
```
### Team/Org Membership Check
```typescript
// convex/lib/teams.ts
import { QueryCtx, MutationCtx } from "./_generated/server";
import { Id } from "./_generated/dataModel";
import { ConvexError } from "convex/values";
import { requireUser } from "./auth";
export async function requireTeamMember(
ctx: QueryCtx | MutationCtx,
teamId: Id<"teams">
) {
const user = await requireUser(ctx);
const membership = await ctx.db
.query("teamMembers")
.withIndex("by_team_and_user", (q) =>
q.eq("teamId", teamId).eq("userId", user._id)
)
.unique();
if (!membership) {
throw new ConvexError({ code: "FORBIDDEN", message: "Team access denied." });
}
return { user, membership };
}
export async function requireTeamAdmin(
ctx: QueryCtx | MutationCtx,
teamId: Id<"teams">
) {
const { user, membership } = await requireTeamMember(ctx, teamId);
if (membership.role !== "admin") {
throw new ConvexError({
code: "FORBIDDEN",
message: "Team admin access required.",
});
}
return { user, membership };
}
```
---
## Argument Validation Patterns
### What Good Validation Looks Like
```typescript
// convex/posts.ts
import { mutation } from "./_generated/server";
import { v } from "convex/values";
import { requireUser } from "./lib/auth";
// GOOD: Strict, explicit, no surprises
export const createPost = mutation({
args: {
title: v.string(),
content: v.string(),
// Enum with v.union + v.literal — not an open v.string()
category: v.union(
v.literal("tech"),
v.literal("design"),
v.literal("business")
),
// IDs must reference the correct table — prevents cross-table injection
tagId: v.optional(v.id("tags")),
},
returns: v.id("posts"),
handler: async (ctx, args) => {
const user = await requireUser(ctx);
return await ctx.db.insert("posts", {
...args,
authorId: user._id, // Never trust a client-provided authorId
createdAt: Date.now(),
});
},
});
// BAD: What not to do
export const createPostUnsafe = mutation({
args: {
data: v.any(), // ❌ Allows any shape — including system fields like _id
},
returns: v.id("posts"),
handler: async (ctx, args) => {
return await ctx.db.insert("posts", args.data); // ❌ Never do this
},
});
```
### ID Validation — Why It Matters
```typescript
// v.id("tasks") does TWO things:
// 1. Validates the ID is structurally correct
// 2. Ensures it references the "tasks" table — not "users" or "payments"
// Without this, a malicious client could pass an ID from another table
// and your ownership check (task.userId === user._id) could pass unexpectedly
export const getTask = query({
args: { taskId: v.id("tasks") }, // ✅ Table-scoped ID validation
returns: v.union(
v.object({ _id: v.id("tasks"), title: v.string() }),
v.null()
),
handler: async (ctx, args) => {
const user = await requireUser(ctx);
const task = await ctx.db.get(args.taskId);
if (!task || task.userId !== user._id) return null;
return task;
},
});
```
---
## Function Exposure Patterns
### Internal vs Public — The Hard Rule
```typescript
// convex/credits.ts
import { mutation, internalMutation } from "./_generated/server";
import { v } from "convex/values";
// PUBLIC mutation: Only handles what the user initiates directly
export const spendCredits = mutation({
args: { amount: v.number() },
returns: v.null(),
handler: async (ctx, args) => {
const user = await requireUser(ctx);
if (user.credits < args.amount) {
throw new ConvexError({ code: "INSUFFICIENT_CREDITS" });
}
// Call the internal mutation — never manipulate credits directly here
// This ensures credit logic is centralized and auditable
await ctx.runMutation(internal.credits._deductCredits, {
userId: user._id,
amount: args.amount,
});
return null;
},
});
// INTERNAL: Credit manipulation logic — unreachable from clients
export const _deductCredits = internalMutation({
args: {
userId: v.id("users"),
amount: v.number(),
},
returns: v.null(),
handler: async (ctx, args) => {
const user = await ctx.db.get(args.userId);
if (!user) throw new ConvexError("User not found");
await ctx.db.patch(args.userId, {
credits: user.credits - args.amount,
});
return null;
},
});
```
### What Must Always Be Internal
- Payment and credit adjustments
- Role and permission changes
- Admin operations triggered by automated logic (crons, webhooks)
- Any function called from a scheduled job or another internal function
---
## Rate Limiting Patterns
### Setup with `@convex-dev/ratelimiter`
```typescript
// convex/lib/rateLimits.ts
import { RateLimiter, MINUTE, HOUR } from "@convex-dev/ratelimiter";
import { components } from "./_generated/api";
// Define all rate limits centrally for type safety
export const rateLimiter = new RateLimiter(components.ratelimiter, {
// Global: limit free signups to deter bot registration
freeSignup: { kind: "fixed window", rate: 100, period: HOUR },
// Per-user: prevent message spam
sendMessage: { kind: "token bucket", rate: 10, period: MINUTE, capacity: 3 },
// Per-user: lock out brute-force login attempts
failedLogin: { kind: "token bucket", rate: 5, period: HOUR },
// Per-user: limit expensive AI calls
aiRequest: { kind: "fixed window", rate: 20, period: HOUR },
});
```
### Applying Rate Limits in Mutations (Not Actions)
```typescript
// convex/messages.ts
import { mutation } from "./_generated/server";
import { v } from "convex/values";
import { rateLimiter } from "./lib/rateLimits";
import { requireUser } from "./lib/auth";
export const sendMessage = mutation({
args: {
content: v.string(),
channelId: v.id("channels"),
},
returns: v.id("messages"),
handler: async (ctx, args) => {
const user = await requireUser(ctx);
// Rate limit check runs inside the mutation (transactional)
// If the mutation fails and rolls back, the rate limit charge rolls back too
await rateLimiter.limit(ctx, "sendMessage", {
key: user._id,
throws: true, // Throws ConvexError automatically if limit exceeded
});
return await ctx.db.insert("messages", {
content: args.content,
channelId: args.channelId,
authorId: user._id,
createdAt: Date.now(),
});
},
});
```
---
## Environment Variable Patterns
```typescript
// convex/actions/email.ts
"use node"; // Required for process.env access
import { action } from "./_generated/server";
import { v } from "convex/values";
import { internal } from "./_generated/api";
export const sendWelcomeEmail = action({
args: {
userId: v.id("users"),
},
returns: v.object({ success: v.boolean() }),
handler: async (ctx, args) => {
// Always check for missing env vars — fail loudly, not silently
const apiKey = process.env.RESEND_API_KEY;
if (!apiKey) {
throw new Error(
"RESEND_API_KEY is not configured. Set it in your Convex dashboard environment variables."
);
}
// Fetch user data via an internal query — never accept user data as action args
const user = await ctx.runQuery(internal.users._getUserById, {
userId: args.userId,
});
if (!user) throw new Error("User not found");
const response = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: "[email protected]",
to: user.email,
subject: "Welcome to YourApp",
html: `<p>Hi ${user.name}, welcome!</p>`,
}),
});
// Never surface raw API error details to callers
if (!response.ok) {
console.error("Email send failed:", response.status);
return { success: false };
}
return { success: true };
},
});
```
---
## Complete Secure Module Example
```typescript
// convex/projects.ts
import { query, mutation, internalMutation } from "./_generated/server";
import { v } from "convex/values";
import { ConvexError } from "convex/values";
import { requireUser, requireAdmin } from "./lib/auth";
import { requireTeamMember, requireTeamAdmin } from "./lib/teams";
import { rateLimiter } from "./lib/rateLimits";
import { internal } from "./_generated/api";
// PUBLIC: User lists their own projects only
export const listMyProjects = query({
args: {},
returns: v.array(
v.object({
_id: v.id("projects"),
name: v.string(),
teamId: v.id("teams"),
createdAt: v.number(),
})
),
handler: async (ctx) => {
const user = await requireUser(ctx);
return await ctx.db
.query("projects")
.withIndex("by_owner", (q) => q.eq("ownerId", user._id))
.collect();
},
});
// PUBLIC: Create a project — rate limited, membership verified
export const createProject = mutation({
args: {
name: v.string(),
teamId: v.id("teams"),
},
returns: v.id("projects"),
handler: async (ctx, args) => {
const { user } = await requireTeamMember(ctx, args.teamId);
// Rate limit project creation per user
await rateLimiter.limit(ctx, "createProject", {
key: user._id,
throws: true,
});
return await ctx.db.insert("projects", {
name: args.name,
teamId: args.teamId,
ownerId: user._id,
createdAt: Date.now(),
});
},
});
// PUBLIC: Delete — ownership check required
export const deleteProject = mutation({
args: {
projectId: v.id("projects"),
},
returns: v.null(),
handler: async (ctx, args) => {
const user = await requireUser(ctx);
const project = await ctx.db.get(args.projectId);
// Same error for "not found" and "not yours" — prevents enumeration
if (!project || project.ownerId !== user._id) {
throw new ConvexError({ code: "NOT_FOUND", message: "Project not found." });
}
// Use internal mutation to clean up related data atomically
await ctx.runMutation(internal.projects._deleteProjectAndData, {
projectId: args.projectId,
});
return null;
},
});
// ADMIN ONLY: List all projects across all teams
export const adminListAllProjects = query({
args: {
limit: v.optional(v.number()),
},
returns: v.array(
v.object({
_id: v.id("projects"),
name: v.string(),
ownerId: v.id("users"),
})
),
handler: async (ctx, args) => {
await requireAdmin(ctx);
return await ctx.db
.query("projects")
.order("desc")
.take(args.limit ?? 50);
},
});
// INTERNAL: Cascading delete — never callable from clients
export const _deleteProjectAndData = internalMutation({
args: { projectId: v.id("projects") },
returns: v.null(),
handler: async (ctx, args) => {
// Delete all tasks belonging to the project
const tasks = await ctx.db
.query("tasks")
.withIndex("by_project", (q) => q.eq("projectId", args.projectId))
.collect();
await Promise.all(tasks.map((t) => ctx.db.delete(t._id)));
// Then delete the project itself
await ctx.db.delete(args.projectId);
return null;
},
});
```
---
## Common Vulnerabilities and How to Prevent Them
| Vulnerability | How It Happens | Prevention |
|---|---|---|
| Unauthenticated data access | Forgot to call `getUserIdentity()` | Use `requireUser()` helper at the top of every sensitive function |
| Cross-user data access | Queried by ID without checking ownership | Always verify `record.userId === user._id` before returning or mutating |
| Privilege escalation | Client passes `role: "admin"` as an arg | Never trust client-provided roles — always read from the database |
| ID injection | Using `v.string()` instead of `v.id("table")` | Always use table-scoped `v.id("tableName")` for document IDs |
| Open writes | `v.any()` or spreading `args.data` into `db.insert()` | List every allowed field explicitly in validators |
| Enumeration attacks | Different errors for "not found" vs "forbidden" | Return identical errors for both cases on sensitive resources |
| Exposed internal logic | Sensitive ops in public functions | Move to `internalMutation` / `internalQuery` |
| Secrets in code | API keys hardcoded or in schema | Use `process.env` in actions only, set via dashboard |
| Unbounded mutations | No rate limits on expensive operations | Add `@convex-dev/ratelimiter` to all costly mutations |
| HTTP action abuse | No origin validation or auth on HTTP endpoints | Validate `Authorization` header and use Zod for request body validation |
---
## Security Audit Workflow
When auditing an existing Convex codebase:
1. **Find all public entry points.** Search for `query(`, `mutation(`, `action(`, `httpAction(` — these are all callable from the internet.
2. **Check each one for auth.** Does it call `requireUser()` or `ctx.auth.getUserIdentity()`? If not, is it intentionally public?
3. **Check each one for authorization.** After auth, does it verify the user can access the specific resource being requested?
4. **Check argument validators.** Does every function have `args: { ... }` with explicit validators? Install `@convex-dev/require-argument-validators` and fix any violations.
5. **Audit internal functions.** Search for `internalMutation`, `internalQuery`, `internalAction`. Anything that modifies credits, roles, payments, or cascades deletes should be here.
6. **Check for `v.any()`.** It should never appear in args for functions that touch the database.
7. **Review HTTP actions.** They need both JWT auth and body validation.
8. **Check rate limiting.** Anything a user can trigger repeatedly (messages, AI calls, uploads, signups) should have limits.
9. **Audit environment variables.** Search for any string that looks like a key or secret in source files.
10. **Review the production deployment.** Confirm schema enforcement is on, and that `CONVEX_DEPLOY_KEY` is only in CI/CD secrets — not in the repo.
---
## Absolute Rules (No Exceptions)
- Never run `npx convex deploy` unless explicitly instructed by the user
- Never run git commands unless explicitly instructed by the user
- Never trust a user ID, role, or permission level provided as a function argument
- Never use `v.any()` for args that touch the database
- Never surface raw internal errors or stack traces to API callers
- Never access `process.env` in a query or mutation — only in actions (`"use node"`)
- Never put API keys, secrets, or tokens in source code or schema definitions
- Never use the same error message to distinguish "not found" from "forbidden" on sensitive resources — use the same message for both to prevent enumeration
---
## References
| Resource | URL |
|---|---|
| Convex Documentation | https://docs.convex.dev |
| Auth in Functions | https://docs.convex.dev/auth/functions-auth |
| Internal Functions | https://docs.convex.dev/functions/internal-functions |
| Argument Validation | https://docs.convex.dev/functions/validation |
| Best Practices | https://docs.convex.dev/understanding/best-practices |
| Production Security | https://docs.convex.dev/production |
| Authorization Guide | https://stack.convex.dev/authorization |
| Rate Limiting Guide | https://stack.convex.dev/rate-limiting |
| Rate Limiter Component | https://www.convex.dev/components/rate-limiter |
| Convex LLMs.txt | https://docs.convex.dev/llms.txt |
```