Shield
@tekir/shield bundles the most common web security primitives: Helmet-style security headers, CSRF protection, Content Security Policy, and XSS sanitization utilities, all in a single zero-dependency package.
Overview
The package exports five distinct building blocks you can compose however you like, plus a shield() convenience factory that wires them all together:
shield(options): composeshelmet,csp, andcsrfinto one middleware array.helmet(options?): sets security-related HTTP response headers.csrf(options?): validates CSRF tokens on mutating requests.csp(options?): sets theContent-Security-Policyheader.sanitize(input)/escapeHtml(input): XSS output helpers.
import { shield, helmet, csrf, csp, sanitize, escapeHtml, csrfToken, rotateCsrfToken } from '@tekir/shield'shield(), All-in-One
Create config/shield.ts to configure all security layers in one place. shield(options) returns an array of middleware that applies helmet first, then csp, then csrf. CSP is applied by default: even if you omit the csp key, shield() installs a sensible default Content Security Policy. Set any component to false to skip it, so disabling CSP now requires an explicit csp: false.
import type { ShieldOptions } from '@tekir/shield'
export default {
helmet: {
hsts: { maxAge: 31536000, preload: true },
frameOptions: 'DENY'
},
csrf: {
exceptPaths: ['/api/'] // skip CSRF for JSON API routes
},
csp: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
upgradeInsecureRequests: true
}
}
} satisfies ShieldOptionsimport type { TekirApp } from '@tekir/core'
import { shield } from '@tekir/shield'
export default function({ router, config }: TekirApp) {
// Pass the config object: shield() returns an array of middleware
router.useGlobal(shield(config('shield')))
}
// Or disable specific components:
// router.useGlobal(shield({ ...config('shield'), csrf: false }))helmet(), Security Headers
helmet() sets a collection of well-known HTTP security headers with sensible defaults. Every header can be individually overridden or disabled by passing false for that option. Headers are pre-computed at middleware creation time so the per-request cost is minimal.
import { helmet } from '@tekir/shield'
// Apply all security headers with default values
router.useGlobal([helmet()])
// Or via shield():
router.useGlobal(shield({ helmet: {} }))Default Headers
The following headers are set when you call helmet() with no arguments:
// Headers set by helmet() with default values:
//
// X-Content-Type-Options: nosniff
// X-Frame-Options: SAMEORIGIN
// X-XSS-Protection: 0
// Strict-Transport-Security: max-age=15552000; includeSubDomains
// X-Download-Options: noopen
// X-Permitted-Cross-Domain-Policies: none
// Referrer-Policy: no-referrer
//
// These are NOT set by default (set to false):
// Cross-Origin-Opener-Policy
// Cross-Origin-Embedder-Policy
// Cross-Origin-Resource-PolicyConfiguring Headers
Pass a HelmetOptions object to override any header. Setting an option to false prevents that header from being set at all:
import { helmet } from '@tekir/shield'
router.useGlobal([helmet({
// X-Frame-Options: prevent clickjacking
frameOptions: 'DENY', // 'DENY' | 'SAMEORIGIN'
// For a single trusted embedder, use CSP frame-ancestors instead.
// frameOptions: false, // disable the header
// Strict-Transport-Security
hsts: {
maxAge: 31536000, // 1 year in seconds (default: 15552000 / 180 days)
includeSubDomains: true, // default: true
preload: true // default: false
},
// hsts: false, // disable HSTS (e.g. during local development)
// Referrer-Policy
referrerPolicy: 'strict-origin-when-cross-origin',
// referrerPolicy: false, // disable
// X-Content-Type-Options
contentTypeOptions: 'nosniff', // only valid value; set false to disable
// X-XSS-Protection: set to '0' to disable the legacy XSS auditor
xssProtection: '0', // default, modern browsers use CSP instead
// X-Download-Options (IE/Edge)
downloadOptions: 'noopen',
// X-Permitted-Cross-Domain-Policies (Flash/PDF)
permittedCrossDomainPolicies: 'none',
// Opt-in Cross-Origin isolation headers (disabled by default)
crossOriginOpenerPolicy: 'same-origin',
crossOriginEmbedderPolicy: 'require-corp',
crossOriginResourcePolicy: 'same-origin'
})])You can also apply helmet() per-route with different settings, for example to allow embedding a specific page in an iframe:
import { helmet } from '@tekir/shield'
// Apply different helmet settings to a specific route
// This route can be iframed because X-Frame-Options is disabled
router.get('/embed', EmbedController.show).use(helmet({ frameOptions: false }))csrf(), CSRF Protection
csrf() protects state-changing endpoints against cross-site request forgery. It stores a random token in the session on the first request and validates the token on every subsequent POST, PUT, PATCH, and DELETE request. The session middleware must run before csrf().
Pass a secret to sign the emitted token. With a secret the session stores only a random value and the token handed to the client is signed with HMAC-SHA256 and verified in constant time, so a token forged against a leaked or shared session store fails verification without the secret. Use your APP_KEY as the secret for a single source of truth.
import type { TekirApp } from '@tekir/core'
import { csrf } from '@tekir/shield'
import { session } from '@tekir/session'
import env from '#env'
export default function({ router, config }: TekirApp) {
// CSRF middleware reads/writes the token from/to the session.
// The session middleware MUST run before csrf().
// Pass a secret to sign the issued tokens (HMAC-SHA256).
router.useGlobal([
session(config('session')),
csrf({ secret: env.APP_KEY })
])
// The middleware validates POST, PUT, PATCH, and DELETE requests
// by comparing the incoming token against the session token.
// GET, HEAD, and OPTIONS are skipped automatically.
}csrfToken()
Call csrfToken(ctx) in a route handler or view helper to retrieve (or lazily create) the session token. Embed it in your HTML forms as a hidden input or pass it to the client as a meta tag for use in AJAX requests:
import { csrfToken } from '@tekir/shield'
import type { HttpContext } from '@tekir/core'
// csrfToken() reads the token from the session (creating it if it doesn't exist)
export async function newPost(ctx: HttpContext) {
const { response } = ctx
const token = csrfToken(ctx)
return response.ok({ csrfToken: token })
}
// In a JSX view, embed the token in a hidden form field:
function NewPostForm({ csrfToken }: { csrfToken: string }) {
return (
<form method="POST" action="/posts">
<input type="hidden" name="_csrf" value={csrfToken} />
{/* ... */}
</form>
)
}For non-form requests (fetch, Axios, etc.) send the token as an X-CSRF-Token header. The middleware reads the token from three locations in priority order:
// For API clients or AJAX requests, send the token as a header:
// X-CSRF-Token: <token>
// The middleware checks (in priority order):
// 1. body._csrf (form hidden field)
// 2. headers['x-csrf-token'] (AJAX header)
// 3. query._csrf (query string)Token Rotation
Call rotateCsrfToken(ctx) on any authentication-state change, typically after login and logout, to discard the old session token and issue a fresh one. This defeats session-fixation: a token captured before login can no longer be replayed afterwards. Pass the same secret you gave to csrf() so the rotated token is signed. To make every accepted token single-use, enable rotateOnUse on the middleware instead of rotating by hand:
import { rotateCsrfToken } from '@tekir/shield'
import type { HttpContext } from '@tekir/core'
// Rotate the token on any authentication-state change so a token captured
// before login cannot be replayed afterwards. Call it after login and logout.
export async function login(ctx: HttpContext) {
// ... verify credentials, set the session ...
const token = rotateCsrfToken(ctx) // issues a fresh token
return ctx.response.ok({ csrfToken: token })
}
// Pass the same secret you gave to csrf() so the rotated token is signed:
// rotateCsrfToken(ctx, '_csrfToken', env.APP_KEY)
// For per-request single-use tokens, enable rotateOnUse on the middleware
// instead of rotating manually: every accepted mutating request rotates the
// stored token automatically.CSRF in SSR Apps
In server-rendered applications, pass the CSRF token to your templates or components. The approach depends on your frontend setup.
Vite + React
Pass the token as a prop to your React component and render it as a hidden form field.
// Vite + React SSR, pass token as a prop
import { csrfToken } from '@tekir/shield'
router.get('/new-post', (ctx) => {
// Pass CSRF token to the React component
return ctx.response.html(renderPage('NewPost', {
csrfToken: csrfToken(ctx),
}))
})
// React component
function NewPost({ csrfToken }) {
return (
<form method="POST" action="/posts">
<input type="hidden" name="_csrf" value={csrfToken} />
<input name="title" placeholder="Title" />
<button type="submit">Create</button>
</form>
)
}Handlebars / EJS / Eta
Pass the token in the template context and use it in your template markup.
// Handlebars, pass token to the template context
import { csrfToken } from '@tekir/shield'
router.get('/new-post', (ctx) => {
return ctx.response.render('posts/new', {
csrfToken: csrfToken(ctx),
})
})
{{!-- posts/new.hbs --}}
<form method="POST" action="/posts">
<input type="hidden" name="_csrf" value="{{csrfToken}}" />
<input name="title" placeholder="Title" />
<button type="submit">Create</button>
</form>SPA / Ajax
For single-page apps, expose the token via an API endpoint or embed it in a meta tag. Then send it as an X-CSRF-Token header with every request.
// SPA / Ajax, read token from a meta tag or endpoint
import { csrfToken } from '@tekir/shield'
// Option 1: Expose via API endpoint
router.get('/api/csrf', (ctx) => {
return { token: csrfToken(ctx) }
})
// Option 2: Embed in HTML as a meta tag
router.get('/*', (ctx) => {
return ctx.response.html(`
<meta name="csrf-token" content="${csrfToken(ctx)}" />
<div id="app"></div>
<script src="/app.js"></script>
`)
})
// Client-side: read meta tag and attach to requests
const token = document.querySelector('meta[name="csrf-token"]').content
fetch('/api/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': token,
},
body: JSON.stringify({ title: 'Hello' }),
})Options
Customize the session key, exempted paths, and protected methods:
import { csrf } from '@tekir/shield'
import env from '#env'
router.useGlobal([csrf({
secret: env.APP_KEY, // sign tokens with HMAC-SHA256 (optional but recommended)
sessionKey: '_csrfToken', // session key for storing the token (default: '_csrfToken')
exceptPaths: ['/api/', '/webhooks/'],
// URL prefixes exempt from CSRF validation.
// API routes that use Bearer-token authentication do not need CSRF protection.
protectedMethods: ['POST', 'PUT', 'PATCH', 'DELETE'],
// HTTP methods that trigger validation (default matches the list above).
rotateOnUse: true
// Rotate the stored token after each accepted mutating request,
// making accepted tokens single-use (default: false).
})])csp(), Content Security Policy
csp(options) sets the Content-Security-Policy header. Directives are written in camelCase and are automatically mapped to their kebab-case header equivalents. Boolean directives (like upgradeInsecureRequests) are serialized without a value:
import { csp } from '@tekir/shield'
router.useGlobal([csp({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", 'https://cdn.example.com'],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'"],
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
objectSrc: ["'none'"],
frameAncestors: ["'none'"],
upgradeInsecureRequests: true // boolean directive, no value needed
}
})])
// Use report-only mode during rollout to see violations without blocking anything
router.useGlobal([csp({
reportOnly: true,
directives: {
defaultSrc: ["'self'"],
reportUri: ['/csp-report']
}
})])Both camelCase and kebab-case directive names are accepted:
// Directives can be written in camelCase (preferred) or kebab-case:
csp({
directives: {
defaultSrc: ["'self'"], // => default-src 'self'
'script-src': ["'self'"], // kebab-case also accepted
upgradeInsecureRequests: true, // => upgrade-insecure-requests
blockAllMixedContent: true // => block-all-mixed-content
}
})CspPresets
CspPresets is an object of pre-quoted keyword strings. CSP keywords must be wrapped in single quotes in the header value, a common mistake when writing them as raw strings. Use CspPresets to avoid that class of bug:
import { csp, CspPresets } from '@tekir/shield'
// CspPresets contains pre-quoted keyword strings so you don't have to
// manually quote them (a common source of bugs):
//
// CspPresets.self => "'self'"
// CspPresets.none => "'none'"
// CspPresets.unsafeInline => "'unsafe-inline'"
// CspPresets.unsafeEval => "'unsafe-eval'"
// CspPresets.strictDynamic => "'strict-dynamic'"
// CspPresets.unsafeHashes => "'unsafe-hashes'"
// CspPresets.data => "data:"
// CspPresets.blob => "blob:"
// CspPresets.https => "https:"
router.useGlobal([csp({
directives: {
defaultSrc: [CspPresets.self],
scriptSrc: [CspPresets.self, CspPresets.strictDynamic],
styleSrc: [CspPresets.self, CspPresets.unsafeInline],
imgSrc: [CspPresets.self, CspPresets.data, CspPresets.https],
objectSrc: [CspPresets.none]
}
})])sanitize() & escapeHtml()
These are output helpers for server-rendered HTML. Use them when displaying user-provided content to prevent XSS.
sanitize(input) strips all HTML tags. <script> and <style> blocks are removed including their content; all other tags are stripped but their text nodes are preserved:
import { sanitize } from '@tekir/shield'
import type { HttpContext } from '@tekir/core'
// Strip all HTML tags: removes <script>, <style>, and any other tags
const clean = sanitize('<script>alert(1)</script><b>Hello</b> World')
// => 'Hello World'
// Note: <script> and <style> blocks including their content are removed
// other tags are stripped but their text content is kept.
// Sanitize user-provided input before storing or displaying it
export async function store({ body, response }: HttpContext) {
const safeBody = sanitize(body.content)
return response.created({ body: safeBody })
}escapeHtml(input) converts HTML special characters to their entity equivalents. Use it when you want to display user content verbatim inside an HTML context without allowing any HTML interpretation:
import { escapeHtml } from '@tekir/shield'
// Escape HTML special characters to their entity equivalents.
// Safe for output inside HTML attribute values and text nodes.
const escaped = escapeHtml('<b>bold</b> & "quoted"')
// => '<b>bold</b> & "quoted"'
// Characters escaped: & < > " ' / ` =
// Use escapeHtml() when rendering user content inside HTML without a templating engine
function renderComment(comment: string) {
return `<p>${escapeHtml(comment)}</p>`
}unescapeHtml(input) is the reverse operation: it decodes HTML entities back to their plain-text characters:
import { unescapeHtml } from '@tekir/shield'
// Reverse of escapeHtml(): decode entities back to plain characters
const raw = unescapeHtml('<b>bold</b>')
// => '<b>bold</b>'For cross-origin request configuration see CORS.