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.