Security
@holo-js/security adds CSRF protection, CORS headers, and rate limiting to your app. Install it when you accept form posts or cookie-authenticated requests from a browser, or when you need to throttle routes like login and registration.
Install
npx holo install securityThis adds @holo-js/security to package.json and creates:
config/security.ts— CSRF and rate-limit settingsconfig/cors.ts— cross-origin rulesconfig/redis.ts— if it doesn't exist yet, for the Redis rate-limit driverstorage/framework/rate-limits/.gitignore— keeps file-based rate-limit data out of GitRATE_LIMIT_DRIVER=filein.envand.env.example
TIP
If you installed authentication, security is already installed and configured.
Register the middleware
CSRF checks, CORS headers, and CORS preflight responses all run in one middleware, csrfProtection(). Projects created with security enabled already include it. If you installed security later, add it yourself:
// proxy.ts
import { csrfProtection } from '@holo-js/security/next/server'
export const proxy = csrfProtection()// server/middleware/csrf.ts
import { csrfProtection } from '@holo-js/security/nuxt/server'
export default csrfProtection()// src/hooks.server.ts
import { sequence } from '@sveltejs/kit/hooks'
import { csrfProtection } from '@holo-js/security/sveltekit/server'
export const handle = sequence(
csrfProtection(),
)The middleware runs before your route code. On every request it:
- Answers CORS preflight (
OPTIONS) requests from allowed origins. - Rejects
TRACErequests with405 Method Not Allowed. - Verifies the CSRF token on
POST,PUT,PATCH, andDELETErequests, and returns419when it's missing or wrong. - Sets the CSRF cookie on
GETandHEADrequests when the browser doesn't have a valid one yet. - Adds CORS headers to responses for matching paths.
Configuration
The generated config/security.ts looks like this:
import { defineSecurityConfig, limit } from '@holo-js/security'
import { env } from '@holo-js/config'
export default defineSecurityConfig({
csrf: {
enabled: true,
field: '_token',
header: 'X-CSRF-TOKEN',
cookie: 'XSRF-TOKEN',
except: [],
},
rateLimit: {
driver: env('RATE_LIMIT_DRIVER', 'file'),
file: {
path: './storage/framework/rate-limits',
},
redis: {
prefix: 'holo:rate-limit:',
},
limiters: {
login: limit.perMinute(5).define(),
register: limit.perHour(10).define(),
},
},
})See CSRF protection for the csrf block, rate limiting for rateLimit, and CORS and proxies for config/cors.ts.
Request body limit
maxBodyBytes caps every request body Holo reads before it runs your code: CSRF token checks, form validation, social sign-in callbacks, broadcast auth, and Realtime requests. The default is 1 MiB (1048576). A request larger than the limit gets a 413 response and is rejected before the body is buffered. The limit is a whole number of bytes:
export default defineSecurityConfig({
maxBodyBytes: 8 * 1024 * 1024,
})Multipart form bodies, which carry file uploads, use a separate limit, maxUploadBodyBytes. Its default is 8 MiB (8388608), the same size as PHP's post_max_size default. Upload size rules that belong to a field, such as a media image limit, still apply on top of it and return 422.
export default defineSecurityConfig({
maxUploadBodyBytes: 16 * 1024 * 1024,
})WARNING
The CSRF cookie is encrypted, and signed tokens are signed, with your app key (key in config/app.ts, usually APP_KEY in .env). If the key is empty, every protected request fails. Changing the key invalidates all existing tokens.
Timing and account enumeration
Auth endpoints that answer differently for known and unknown accounts can reveal which emails have accounts. Holo's login and reset-link requests take at least 200 ms for both cases. A reset-link request still waits on mail delivery when the email is sent synchronously, so queue the reset email to remove that difference. See Request a reset link.
Error statuses
| Failure | Status | Where it comes from |
|---|---|---|
| Missing or wrong CSRF token | 419 | csrfProtection() middleware or protect() |
| Too many attempts | 429 | rateLimit(), protect({ throttle }), or validate(..., { throttle }) |
TRACE request | 405 | csrfProtection() middleware |
Validation failures (422) are separate. See error responses.
Available functions
Setup
CSRF
CORS
Rate limiting
Signed tokens and URLs
Errors
defineSecurityConfig()
Wraps config/security.ts so its options are typed. Import it from @holo-js/security or @holo-js/security/config.
import { defineSecurityConfig } from '@holo-js/security'
export default defineSecurityConfig({
csrf: { enabled: true },
rateLimit: { driver: 'file' },
})defineCorsConfig()
Wraps config/cors.ts so its options are typed. Import it from @holo-js/security or @holo-js/security/config.
import { defineCorsConfig } from '@holo-js/security'
export default defineCorsConfig({
paths: ['/api/*'],
origins: ['https://app.example.com'],
})See CORS and proxies.
csrfProtection()
Returns the middleware that handles CSRF checks, CORS headers, and CORS preflight for every request. Import it from @holo-js/security/next/server, @holo-js/security/nuxt/server, or @holo-js/security/sveltekit/server. Register it as shown in Register the middleware.
default export security
The default export of @holo-js/security is one object that bundles the helpers on this page, such as csrf, cors, protect, limit, rateLimit, and the signed-token functions. Use the named imports in most code.
import security from '@holo-js/security'
await security.protect(request)csrf.token()
Returns the CSRF token stored in the request's session.
const token = await csrf.token(request)See token helpers.
csrf.field()
Returns the form field's name and value.
const { name, value } = await csrf.field(request)csrf.input()
Returns a hidden input description (type, name, value) that you can render in a form.
const input = await csrf.input(request)csrf.cookie()
Returns the Set-Cookie value for the XSRF-TOKEN cookie, which holds the session token encrypted. Pass an explicit token as the second argument to encrypt that token instead.
response.headers.set('Set-Cookie', await csrf.cookie(request))csrf.verify()
Checks the request's CSRF token and throws SecurityCsrfError when it's missing or wrong. It skips safe methods and except paths.
await csrf.verify(request)protect()
Runs the CSRF check and an optional rate limit inside a route. Pass csrf: true to force the check, csrf: false to skip it, or throttle: 'login' to hit a named limiter first.
await protect(request, { throttle: 'login' })See Check CSRF inside a route.
isSecureRequest()
Returns true when the request's URL uses https:.
const secure = isSecureRequest(request)getSecurityClientConfig()
Imported from @holo-js/security/client. Returns the CSRF field, header, and cookie names, for browser code that sends the token back.
import { getSecurityClientConfig } from '@holo-js/security/client'
const { csrf } = getSecurityClientConfig()See sending the token with fetch.
cors.headers()
Returns a Headers object with the CORS headers for the request. It's empty when the path isn't in paths or the origin isn't allowed.
const corsHeaders = cors.headers(request)cors.preflight()
Returns the preflight response for an allowed OPTIONS request, or null for any other request.
export async function OPTIONS(request: Request) {
return cors.preflight(request) ?? new Response(null, { status: 204 })
}cors.apply()
Returns a copy of a response with CORS headers added. Without a response, it returns an empty 204.
return cors.apply(request, Response.json({ posts: [] }))limit.perMinute()
Starts a limiter that allows maxAttempts per 60 seconds. Call .define() or .by() to finish it. maxAttempts must be an integer of at least 1.
limiters: {
login: limit.perMinute(5).define(),
}limit.perHour()
Starts a limiter that allows maxAttempts per 3600 seconds. Finish it with .define() or .by().
limiters: {
register: limit.perHour(10).define(),
}.by()
Sets a custom key resolver on a limiter. The resolver gets the request and any values, and returns the key to count against.
login: limit.perMinute(5).by(({ request }) => defaultRateLimitKey(request)),See custom keys.
.define()
Finishes a limiter with the default key, which is the signed-in user or the client IP.
login: limit.perMinute(5).define(),defineRateLimiter()
Builds a limiter from maxAttempts, decaySeconds, and an optional key resolver. Use it when you need a window other than a minute or an hour.
passwordReset: defineRateLimiter({ maxAttempts: 3, decaySeconds: 900 }),See rate limiting.
rateLimit()
Counts one attempt for a named limiter. Pass request to use the limiter's key, or key to set your own. It returns the hit result with snapshot.attempts and snapshot.remainingAttempts, and throws SecurityRateLimitError when the limit is exceeded.
const result = await rateLimit('api', { request })See rate-limit anything.
clearRateLimit()
Resets counters. Pass { limiter, key } to reset one key, { limiter } to reset every key for a limiter, or { all: true } to reset everything. It returns a count, or a boolean when you clear a single key.
await clearRateLimit({ limiter: 'login', key: `user:${userId}` })See reset counters.
defaultRateLimitKey()
Returns the key a limiter uses when you don't set one: the signed-in user's key when auth is installed, otherwise ip:<client-ip>.
const key = await defaultRateLimitKey(request)ip()
Returns the visitor's IP address. Holo uses the address of the connection, or reads it from your trusted proxies.
const address = ip(request)In a Next.js app without trustedProxies: '*', it throws, because Next.js doesn't expose the connection address.
See trusted proxies.
createSignedToken()
Creates a tamper-proof token that carries a JSON payload and expires at expiresAt. Use a different purpose for each kind of link.
const token = createSignedToken({ exportId: 'export-1' }, {
purpose: 'export-download',
expiresAt: new Date(Date.now() + 15 * 60 * 1000),
})See signed tokens.
verifySignedToken()
Returns the payload when the token is valid, unexpired, and has the same purpose. Returns null otherwise. Pass now to check against a specific time.
const payload = verifySignedToken<{ readonly exportId: string }>(token, {
purpose: 'export-download',
})resolveRequestUrl()
Imported from @holo-js/kernel. Returns the URL the visitor used, following your trusted proxies. Use it to build absolute links.
const url = resolveRequestUrl(request)See read the public URL.
SecurityCsrfError
The error thrown when a CSRF check fails. It has status: 419.
if (error instanceof SecurityCsrfError) {
return Response.json({ message: error.message }, { status: error.status })
}SecurityRateLimitError
The error thrown when a limiter is exceeded. It has status: 429, retryAfterSeconds, and snapshot.
if (error instanceof SecurityRateLimitError) {
return Response.json({ message: error.message }, { status: 429 })
}