Auth
Security Best Practices
Guidelines for safely exposing Surf commands
Security Best Practices#
When adding Surf to your website, follow these guidelines to avoid accidentally exposing internal functionality:
Only expose what's already public
- Commands should mirror actions a regular user can already perform through the UI
- Do not expose internal APIs, admin endpoints, database queries, or backend services
- Use
auth: 'required'for any command that modifies data or acts on behalf of a user - Use
auth: 'hidden'for admin or internal commands that should not appear in the public manifest - When in doubt, leave it out — explicitly opt-in to each command you expose
- Review your
surf.jsonmanifest to verify only intended commands are listed
Think of Surf commands as a curated public API — not a proxy to your entire backend. A good rule of thumb: if a user can't do it from the browser without logging in, it shouldn't be an unauthenticated Surf command.
Design for zero prior knowledge
Agents arrive with no context about your site — no IDs, slugs, or internal references. Design your commands so an agent can explore everything starting from just the manifest.
- Every ID-based command (e.g.
article.get) must be reachable from a discovery command (e.g.article.list,search) - List and search commands should return items with their IDs, so agents can discover then drill down
- Good pattern: search → get details → take action (never require an ID without a way to find it)
- Include feed or browse commands as entry points — don't assume the agent knows what to ask for
Rate Limiting
Built-in sliding-window rate limiting, configurable globally or per-command. Supports multiple key strategies.
const surf = await createSurf({ name: 'My Store', // Global rate limit — applies to all commands rateLimit: { windowMs: 60_000, // 1-minute window maxRequests: 100, // 100 requests per window keyBy: 'ip', // Group by: 'ip' | 'session' | 'auth' | 'global' }, commands: { search: { description: 'Search products', // Per-command override — stricter limit rateLimit: { windowMs: 60_000, maxRequests: 30, keyBy: 'ip', }, params: { query: { type: 'string', required: true } }, run: async ({ query }) => db.search(query), }, 'order.create': { description: 'Create order', auth: 'required', // Rate limit by authenticated user rateLimit: { windowMs: 3600_000, // 1 hour maxRequests: 10, keyBy: 'auth', }, run: async (params, ctx) => { /* ... */ }, }, },})When rate limited, responses include a Retry-After header and return the RATE_LIMITED error code (HTTP 429).
Middleware
Middleware intercepts command execution. Each middleware receives a context and a next function. Use it for logging, tracing, analytics, or custom validation.
import { createSurf } from '@surfjs/core'import type { SurfMiddleware } from '@surfjs/core' // Logging middlewareconst logger: SurfMiddleware = async (ctx, next) => { const start = Date.now() console.log(`→ ${ctx.command}`, ctx.params) await next() const ms = Date.now() - start if (ctx.result) console.log(`← ${ctx.command} OK (${ms}ms)`) if (ctx.error) console.log(`← ${ctx.command} ERROR (${ms}ms)`)} const surf = await createSurf({ name: 'My API', middleware: [logger], commands: { /* ... */ },}) // Or add middleware after creationsurf.use(async (ctx, next) => { // ctx.command — command name // ctx.params — validated parameters // ctx.context — execution context (sessionId, auth, claims, ip, state) // ctx.result — set by handler (available after next()) // ctx.error — set on error (short-circuits pipeline) await next()})⚠️ Note: Auth middleware is auto-installed when you provide an
authVerifier. It runs before any custom middleware.
Strict Mode
Enable strict mode to validate command return values against their declared returns schema. This catches bugs before they reach your agents.
const surf = await createSurf({ name: 'My API', strict: true, // enables validateReturns + other strict checks commands: { search: { description: 'Search products', params: { query: { type: 'string', required: true } }, returns: { type: 'object', properties: { results: { type: 'array', items: { type: 'object' } }, total: { type: 'number' }, }, }, run: async ({ query }) => { // Return value is validated against the schema above return { results: [...], total: 42 } }, }, },})💡 Tip: You can also enable return validation selectively with
validateReturns: trueinstead of full strict mode.
Security Headers
For production deployments, add HTTP security headers at the framework level. Surf middleware operates at the command execution layer and does not have access to the HTTP response object — security headers must be set by your HTTP framework (Express, Hono, etc.).
Express:
import helmet from 'helmet'import express from 'express'import { createSurf } from '@surfjs/core' const surf = await createSurf({ name: 'My API', commands: { /* ... */ } }) const app = express()app.use(helmet()) // Sets all recommended security headersapp.use(express.json())app.use(surf.middleware())app.listen(3000)Hono:
import { Hono } from 'hono'import { secureHeaders } from 'hono/secure-headers'import { createSurf, honoApp } from '@surfjs/core' const surf = await createSurf({ name: 'My API', commands: { /* ... */ } }) const app = new Hono()app.use('*', secureHeaders()) // Sets all recommended security headersapp.route('/', await honoApp(surf)) export default appYou can customize individual headers through the framework's middleware options:
| Header | Default | Purpose |
|--------|---------|---------|
| Content-Security-Policy | default-src 'self'; ... | Controls which resources the browser can load |
| Strict-Transport-Security | max-age=31536000; includeSubDomains | Forces HTTPS for one year |
| X-Frame-Options | DENY | Prevents clickjacking via iframes |
| X-Content-Type-Options | nosniff | Prevents MIME-type sniffing |
| Referrer-Policy | strict-origin-when-cross-origin | Limits referrer information leaking |
| Permissions-Policy | camera=(), microphone=(), ... | Restricts browser feature access |
| X-Permitted-Cross-Domain-Policies | none | Blocks Flash/Acrobat cross-domain access |
💡 Tip: If your Surf API runs behind a reverse proxy (Nginx, Cloudflare) that already handles HSTS, disable it in the middleware:
securityHeaders({ strictTransportSecurity: false })
📦 Full example: See the security-headers example in the repo for a complete, runnable recipe.