Skip to content

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.json manifest 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.

TypeScript
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.

TypeScript
import { createSurf } from '@surfjs/core'
import type { SurfMiddleware } from '@surfjs/core'
 
// Logging middleware
const 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 creation
surf.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.

TypeScript
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: true instead 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:

TypeScript
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 headers
app.use(express.json())
app.use(surf.middleware())
app.listen(3000)

Hono:

TypeScript
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 headers
app.route('/', await honoApp(surf))
 
export default app

You 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.