Appearance
Security
@holo-js/security is the optional package for CSRF protection, CORS, and rate limiting.
Install it only when the app needs browser form protection or named request throttles:
bash
npx holo install securityThat writes config/security.ts and config/cors.ts, adds @holo-js/security, and lets core boot the package lazily only when it is installed.
If you installed Auth, Security is already installed and configured. You do not need to run this command again.
What the package owns
- CSRF token helpers for server-rendered forms and browser clients
- CORS headers for separate frontend/API deployments
- request protection for unsafe HTTP methods through framework middleware
- route-level protection for plain routes with
protect(...) - named rate limiters with
limit.perMinute(...)andlimit.perHour(...) - low-level
rateLimit(...)andclearRateLimit(...)helpers - optional integration with
@holo-js/formsfor named request throttles
throttle stays server-only. The browser never meaningfully enforces the rate limit.
Configuration
Security uses separate config entrypoints: cross-origin rules live in config/cors.ts, while CSRF and rate limit settings live in config/security.ts:
ts
import { defineSecurityConfig, limit } from '@holo-js/security'
export default defineSecurityConfig({
csrf: {
enabled: true,
field: '_token',
header: 'X-CSRF-TOKEN',
cookie: 'XSRF-TOKEN',
except: [
'/webhooks/*',
],
},
rateLimit: {
driver: 'file',
file: {
path: './storage/framework/rate-limits',
},
redis: {
connection: 'cache',
prefix: 'holo:rate-limit:',
},
limiters: {
login: limit.perMinute(5).define(),
register: limit.perHour(10).define(),
api: limit.perMinute(60).define(),
},
},
})config/cors.ts controls cross-origin API access:
ts
import { env } from '@holo-js/config'
import { defineCorsConfig } from '@holo-js/security'
export default defineCorsConfig({
paths: ['/api/*', '/broadcasting/config', '/broadcasting/auth'],
origins: [
env('FRONTEND_URL', 'http://localhost:3000'),
],
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
headers: ['Content-Type', 'Authorization', 'X-CSRF-TOKEN', 'X-Requested-With'],
credentials: true,
maxAge: 7200,
statefulDomains: [
env('FRONTEND_DOMAIN', 'localhost:3000'),
],
})When rateLimit.driver is redis, rateLimit.redis.connection points to a named connection in config/redis.ts.
Example shared Redis config:
ts
import { env } from '@holo-js/config'
import { defineRedisConfig } from '@holo-js/kernel'
export default defineRedisConfig({
default: 'cache',
connections: {
cache: {
url: env('REDIS_URL') || undefined,
host: env('REDIS_HOST', '127.0.0.1'),
port: env('REDIS_PORT', 6379),
username: env('REDIS_USERNAME'),
password: env('REDIS_PASSWORD'),
db: env('REDIS_DB', 0),
},
},
})Shared Redis connections resolve in this order:
urlclustershost
So if url is present, it wins. Otherwise cluster mode is used when clusters exists. Otherwise Holo-JS falls back to standalone host, which may also be a Unix socket path.
Config rules
csrf.enabledcontrols the default CSRF behavior for route protection.csrf.fieldis the hidden form field name for normal form posts.csrf.headeris the header accepted for XHR andfetchrequests.csrf.cookiestores the signed readable token cookie that browser clients and native forms submit back.csrf.exceptskips CSRF verification for matching paths such as webhooks./broadcasting/authis skipped by CSRF protection automatically when broadcast is installed.cors.originslists frontend origins allowed to call the API.cors.credentialsmust be true when the frontend uses cookie-backed auth withfetch(..., { credentials: 'include' }).cors.statefulDomainslists browser hosts that should be treated as first-party cookie clients.rateLimit.drivermust bememory,file, orredis.- MFA uses the selected rate-limit driver. Use
rediswhen multiple application instances must share attempt limits. rateLimit.redis.connectionmust reference a named shared Redis connection whenrateLimit.driverisredis.rateLimit.limitersis the named limiter registry used byvalidate(...),protect(...), andrateLimit(...).- When a limiter uses
define()instead ofby(...), the package uses its default key strategy. - The default key is
user:<id>when the current Holo auth runtime can resolve an authenticated user. - Otherwise the default key falls back to
ip:<client-ip>from the incoming request headers. - The runtime only reads
x-forwarded-forandx-real-ipwhenHOLO_SECURITY_TRUST_PROXYis truthy. - The same trust setting controls
Forwardedandx-forwarded-protowhen determining secure requests and cookies. - Security middleware rejects
TRACEwith405; enable unusual HTTP methods outside the security middleware only when the application deliberately owns their handling. - Without trusted proxy headers, guest requests can fall back to
ip:unknown, which means multiple anonymous clients may share the same limiter bucket and get throttled together. - If your app sits behind trusted proxies or needs additional identifier scoping, either override the limiter key with
by(...)or enableHOLO_SECURITY_TRUST_PROXYfor those trusted proxies.
Forms
CSRF is enforced by middleware before route handlers run. Form validation does not opt into CSRF; it only validates field data after the request passes the middleware.
Validation failures, CSRF failures, and auth failures stay separate:
csrfProtection()verifies unsafe requests before route handlers run and returns419on token mismatch.validate(...)throws validation failures such as missing fields, bad formats, and throttling asValidationException.login(...),register(...),verifyEmail(...),requestPasswordReset(...), andresetPassword(...)throwValidationExceptionfor expected auth failures.- Forms adapters handle those auth exceptions through the same status and field-error path as schema validation.
Login
ts
import { validate } from '@holo-js/forms'
import { field, schema } from '@holo-js/validation'
import { login } from '@holo-js/auth'
const loginForm = schema({
email: field.string().required().email(),
password: field.password().required().min(8),
})
export async function POST(request: Request) {
const data = await validate(request, loginForm, {
throttle: 'login',
})
await login(data)
return Response.json({
ok: true,
message: 'Logged in.',
})
}Register
ts
import { validate } from '@holo-js/forms'
import { field, schema } from '@holo-js/validation'
import { register } from '@holo-js/auth'
const registerUser = schema({
name: field.string().required().min(3).max(255),
email: field.string().required().email(),
password: field.password().required().min(8).confirmed(),
passwordConfirmation: field.password().required(),
})
export async function POST(request: Request) {
const data = await validate(request, registerUser, {
throttle: 'register',
})
await register(data)
return Response.json({
ok: true,
message: 'Account created.',
})
}Failure statuses
- validation failures return
422 - CSRF middleware failures return
419 - rate-limit failures return
429
validate(...) includes rate-limit metadata on the serialized validation exception:
ts
{
ok: false,
status: 429,
retryAfterSeconds: 60,
}useForm(...)
When @holo-js/security is installed and its CSRF cookie exists, useForm(...) automatically attaches the CSRF field to unsafe FormData submissions. No CSRF option is needed:
ts
const form = useForm(registerUser, {
validateOn: 'blur',
initialValues: {
name: '',
email: '',
password: '',
passwordConfirmation: '',
},
async submitter({ formData }) {
return await $fetch('/api/register', {
method: 'POST',
body: formData,
})
},
})The actual protection happens in the framework middleware before route code runs. The middleware also passes the configured CSRF field and cookie names to browser helpers, so config/security.ts stays the only source of truth.
Do not put throttle on useForm(...). Throttling is enforced on the server through validate(request, schema, { throttle: 'name' }) or protect(request, { throttle: 'name' }).
Without form validation
CSRF and rate limiting do not require validate(...). Use the security package directly when a route does not parse a Holo form schema, or when request parsing is handled by another layer.
Keep global framework middleware enabled for normal CSRF verification. It runs before route handlers and does not depend on form validation.
Use protect(...) with no options when a route only needs CSRF verification. This follows config/security.ts, so safe methods and security.csrf.except paths are skipped:
ts
import { protect } from '@holo-js/security'
export async function POST(request: Request) {
await protect(request)
return Response.json({ ok: true })
}Use protect(...) with throttle when a route needs both CSRF verification and rate limiting without calling validate(...):
ts
import { protect } from '@holo-js/security'
export async function POST(request: Request) {
await protect(request, {
throttle: 'api',
})
return Response.json({ ok: true })
}Use protect(...) with csrf: false when a route only needs rate limiting:
ts
import { protect } from '@holo-js/security'
export async function POST(request: Request) {
await protect(request, {
csrf: false,
throttle: 'api',
})
return Response.json({ ok: true })
}Use rateLimit(...) directly when the code is not route middleware:
ts
import { rateLimit } from '@holo-js/security'
await rateLimit('api', { request })
await rateLimit('send-invite', {
key: `team:${teamId}:user:${userId}`,
})CSRF helpers
Use CSRF helpers when rendering native server forms or building custom request handling. The framework middleware remains the normal verification path.
Server-rendered hidden field
ts
import { csrf } from '@holo-js/security'
const input = await csrf.input(request)input has the shape:
ts
{
type: 'hidden',
name: '_token',
value: '...',
}Global CSRF middleware
Framework middleware issues the readable CSRF cookie on safe requests and verifies unsafe requests before route actions run. Generated apps wire this globally. If you wire it manually, use the framework entrypoint:
ts
export { csrfProtection as proxy } from '@holo-js/security/next/server'ts
export { csrfProtection as default } from '@holo-js/security/nuxt/server'ts
import { sequence } from '@sveltejs/kit/hooks'
import { csrfProtection } from '@holo-js/security/sveltekit/server'
export const handle = sequence(
csrfProtection(),
)csrfProtection() respects security.csrf.except, so webhook paths can opt out from verification.
Use csrf.cookie(request) directly only for custom server-rendered HTML outside framework middleware.
Route protection without framework middleware
ts
import { protect } from '@holo-js/security'
export async function POST(request: Request) {
await protect(request, {
throttle: 'api',
})
return Response.json({ ok: true })
}Rate limiting
Named limiters are the normal path:
ts
import { defineSecurityConfig, limit } from '@holo-js/security'
export default defineSecurityConfig({
rateLimit: {
driver: 'file',
limiters: {
login: limit.perMinute(5).define(),
register: limit.perHour(10).define(),
},
},
})define() is the default path. It gives you the default framework behavior without repeating a key resolver on every limiter: authenticated requests use user:<id> and guest requests fall back to ip:<client-ip>.
Overriding the default key
Override the key when a limiter needs more than the built-in user-or-IP fallback. A common case is login: keep the default base key, then add an opaque identifier so one email cannot be hammered across many IPs and raw email addresses never land in rate-limit storage.
ts
import { createHmac } from 'node:crypto'
import { defaultRateLimitKey, defineSecurityConfig, limit } from '@holo-js/security'
function getOpaqueKeyFromEmail(email: string): string {
const appKey = process.env.APP_KEY
if (!appKey) {
throw new Error('APP_KEY must be set before deriving opaque rate-limit keys.')
}
return createHmac('sha256', appKey)
.update(email.trim().toLowerCase())
.digest('hex')
}
export default defineSecurityConfig({
rateLimit: {
driver: 'file',
limiters: {
login: limit.perMinute(5).by(async ({ request, values }) => {
const email = typeof values?.email === 'string' ? values.email.toLowerCase() : 'guest'
return `${await defaultRateLimitKey(request)}:email:${getOpaqueKeyFromEmail(email)}`
}),
register: limit.perHour(10).define(),
},
},
})Plain routes and actions
ts
await protect(request, {
throttle: 'api',
})Arbitrary methods
Use the low-level helper when the code is not going through validate(...) or protect(...):
ts
import { rateLimit } from '@holo-js/security'
await rateLimit('login', { request })Or use an explicit key when there is no Request:
ts
await rateLimit('send-invite', {
key: `team:${teamId}:user:${userId}`,
})Clearing counters
Programmatic helper:
ts
import { clearRateLimit } from '@holo-js/security'
await clearRateLimit({ limiter: 'api', key: 'user:42' })
await clearRateLimit({ limiter: 'api', key: 'ip:203.0.113.7' })
await clearRateLimit({ limiter: 'login' })
await clearRateLimit({ all: true })CLI helper:
bash
npx holo rate-limit:clear --limiter api --key "user:42"
npx holo rate-limit:clear --limiter api --key "ip:203.0.113.7"
npx holo rate-limit:clear --limiter login
npx holo rate-limit:clear --allDriver persistence
| Driver | Persists after reload | Works across multiple app instances | CLI clear story |
|---|---|---|---|
memory | No | No | Not meaningful from the CLI because the counters live in the app process |
file | Yes, on the same machine | No | Supports key, limiter, and --all clears |
redis | Yes | Yes | Supports key, limiter, and --all clears through the Redis adapter |
Use memory for local development and tests. Use file when one machine needs persistence across restarts. Use redis when the app runs on multiple instances or when rate-limit state must survive deploys and reloads.
Nuxt request handling
Throttle-aware validate(...) calls need a real web Request or request-like event. In Nuxt, pass the h3 event directly when you want request-based limiter keys:
ts
import { defineEventHandler } from 'h3'
import { validate } from '@holo-js/forms'
import { field, schema } from '@holo-js/validation'
const loginForm = schema({
email: field.string().required().email(),
password: field.password().required().min(8),
})
export default defineEventHandler(async (event) => {
const data = await validate(event, loginForm, {
throttle: 'login',
})
return {
ok: true,
data,
message: 'Logged in.',
}
})If you pass only a plain body object, validation still works, but request-based limiter keys cannot be generated.
Typing
The public API is fully typed and normal usage should infer everything without manual generics.
Examples:
defineSecurityConfig(...)infersmemory,file, andredisdriver config correctly- limiter callbacks infer
requestandvalues validate(requestOrEvent, schema, { throttle })keeps the schema-derived success and failure typesuseForm(schema)keeps field, value, and error inference while client CSRF attachment stays automatic- public contracts such as
SecurityRateLimitStore,SecurityRateLimitHitResult, andSecurityRateLimitRedisDriverAdapterare exported when you need explicit annotations
Optional package behavior
Security stays optional:
- install it with
npx holo install security - include it during project creation only if the app needs it
- apps that do not install it do not pay dependency or runtime cost
@holo-js/formsloads it lazily only when server throttling or browser CSRF attachment is actually used
If code uses validate(..., { throttle }) without the package installed, Holo throws a targeted error instead of silently pretending the route is rate-limited.