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): composes helmet, csp, and csrf into one middleware array.
  • helmet(options?): sets security-related HTTP response headers.
  • csrf(options?): validates CSRF tokens on mutating requests.
  • csp(options?): sets the Content-Security-Policy header.
  • 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.

config/shield.ts
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 ShieldOptions
start/kernel.ts
import 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-Policy

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

start/kernel.ts
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"')
// => '&lt;b&gt;bold&lt;&#x2F;b&gt; &amp; &quot;quoted&quot;'

// 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('&lt;b&gt;bold&lt;&#x2F;b&gt;')
// => '<b>bold</b>'

For cross-origin request configuration see CORS.