Skip to content

Security

Overview

Security is enforced at two layers in this stack:

  1. Nuxt server layer — Nitro middleware that runs on every request before it reaches a route handler or the backend proxy
  2. Backend API layer — Azure Functions middleware that re-enforces auth and RBAC on every request that arrives at the Functions host

Neither layer alone is sufficient. The backend validates every request independently so that direct API access (bypassing Nuxt) is also protected.

Nuxt server middleware execution order

All Nitro server middleware files live in nuxt-frontend/server/middleware/ and execute in this order on every request:

OrderFilePurpose
1security-headers.tsSets CSP, HSTS, X-Frame-Options, etc.
2rate-limit.tsBlocks brute-force login attempts
3csrf.tsDouble-submit cookie CSRF protection
4api-auth.tsJWT validation and admin role enforcement
5okta-session.tsOkta OIDC session → backend JWT exchange

Security headers (security-headers.ts)

Applied to every response. Configured differently for development and production:

HeaderDevProduction
Content-Security-PolicyPermissive — unsafe-eval for Vue HMR, localhost:* connect sourcesTightened — no unsafe-eval, no localhost URLs
Strict-Transport-SecurityNot setmax-age=31536000; includeSubDomains
X-Content-Type-Optionsnosniffnosniff
X-Frame-OptionsDENYDENY
Referrer-Policystrict-origin-when-cross-originstrict-origin-when-cross-origin
Permissions-Policygeolocation=(self), microphone=(), camera=(), payment=()Same
X-Powered-ByRemovedRemoved

Production CSP details

default-src 'self'
script-src 'self' 'unsafe-inline' https://unpkg.com
style-src 'self' 'unsafe-inline' https://unpkg.com https://fonts.googleapis.com
img-src 'self' data: blob: https://*.tile.openstreetmap.org https://*.blob.core.windows.net
font-src 'self' data: https://fonts.gstatic.com https://fonts.googleapis.com
connect-src 'self' wss:
frame-ancestors 'none'

Known unsafe-inline gaps to resolve:

  • script-src 'unsafe-inline' is needed for Nuxt hydration payload injection — a nonce-based CSP strategy using a Nitro plugin would eliminate this.
  • style-src 'unsafe-inline' is needed because PrimeVue injects inline styles — resolves when PrimeVue adds nonce support.

Rate limiting (rate-limit.ts)

Protects login endpoints only. Applied in production.

  • Target: POST /api/auth/* routes
  • Window: 15 minutes
  • Limit: 5 attempts per IP address
  • Storage: In-process Map with a 5-minute cleanup interval
  • Response on limit: HTTP 429 with X-RateLimit-* headers
  • Note: Because this is in-process, limits reset on deploy or process restart. For persistent limits across instances, replace with a Redis-backed counter.

CSRF protection (csrf.ts)

Double-submit cookie pattern. Applied in production only.

  • Checked on: POST, PUT, PATCH, DELETE to /api/*
  • Skip list: /api/auth/login, /api/auth/backend-login, /api/auth/csrf-token, /api/health
  • Mechanism: The csrf_token cookie value must match the X-CSRF-Token request header — a browser-controlled cross-origin request cannot read the cookie value to replay it
  • Token generation: GET /api/auth/csrf-token sets the cookie and returns the token value; the frontend includes it on all mutations via useApi

Server-side auth enforcement (api-auth.ts)

Guards all /api/* routes in production.

Public paths (always skip):
  /api/auth/*
  /api/health
  /api/dev-login

Protected path logic:
  /api/admin/* and /api/audit/* → enforce auth on all methods
  Everything else              → enforce auth on mutations (POST/PUT/DELETE/PATCH) only
                                 GET requests pass through (backend re-validates via proxied cookie)

Enforcement:
  1. Read auth_token httpOnly cookie
  2. Verify JWT signature with HMAC-SHA256 using apiSecret
  3. Check token expiry
  4. For /api/admin/* → require role === 'admin'
  5. Attach decoded payload to event.context.auth
  6. Log denied attempts to audit-logger
CookiehttpOnlyPurpose
auth_tokenYesSigned JWT — readable only by the Nitro server; never accessible to client JS
auth_userNoJSON with userId, email, displayName, role — used by client for UI state
auth_permissionsNoJSON array of permission strings — used by client for UI gating
auth_token_expiryNoMillisecond timestamp — used by client to detect session expiry

The auth_token httpOnly design means:

  • Client JS cannot read or forge it
  • The Nitro proxy layer reads it server-side and injects it as Authorization: Bearer when proxying to the backend
  • Client-side auth state is driven by auth_user and auth_token_expiry cookies only

Important: useCookie('auth_token') in Vue/composable code always returns null by design.

JWT token format

The Nuxt server/utils/jwt.ts module handles two token formats because the backend uses a legacy signing format:

FormatPartsEncodingExpiry unit
Standard JWT3 (header.payload.signature)base64urlSeconds since epoch
Backend legacy2 (data.signature)base64 + hex HMACMilliseconds since epoch

Both formats use HMAC-SHA256 signing. Signature comparison uses crypto.timingSafeEqual to prevent timing attacks.

OIDC session middleware (Okta)

When Okta is configured, okta-session.ts transparently exchanges an OIDC session for a backend JWT, so all downstream proxy routes continue to work without modification:

Okta OIDC session detected (via nuxt-oidc-auth getUserSession)
  → No existing auth_token cookie
  → POST /auth/login to backend with {email, name, okta: true, groups}
  → Backend returns {success: true, data: {token, user, expiresIn}}
  → Nitro sets auth_token, auth_user, auth_permissions, auth_token_expiry, auth_via_oidc cookies
  → Subsequent requests carry the backend JWT transparently

A 5-minute oidc_exchange_failed cookie prevents redirect loops when the OIDC session exists but no matching app account exists.

Route middleware (client-side)

Located in nuxt-frontend/middleware/:

FileTypePurpose
auth.tsNamedRedirects unauthenticated users to /login (or OIDC /auth/login)
device.global.tsGlobalSwitches defaultmobile layout on every navigation based on viewport
permissions.tsNamedFine-grained RBAC; used with permissions:[] or permissionsAny:[] in definePageMeta

Using auth and permissions together

vue
<script setup>
definePageMeta({
  middleware: ['auth', 'permissions'],
  // Strict mode — user must have ALL listed permissions
  permissions: ['personnel.read', 'personnel.create']
})
</script>
vue
<script setup>
definePageMeta({
  middleware: ['auth', 'permissions'],
  // Loose mode — user needs ANY of these
  permissionsAny: ['personnel.read', 'emergency.read']
})
</script>

Permission strings use dot notation: resource.action (e.g. emergency.trigger, admin.users).

Backend API security (static-web-app/api/src/middleware/)

FilePurpose
auth.jsDecodes JWT and attaches user to request context
rbac.jsrequirePermission(resource, action) — evaluates loaded role/permission set
rateLimit.jsConfigurable per-route rate limiting on the Functions host
performance.jsRequest timing and logging
utils/withAuth.jswithAuth wrapper (authenticated routes) and withPublic wrapper (public routes)

The backend independently validates auth on every request. Requests arriving directly at the Functions host (bypassing Nuxt) are subject to the same auth and RBAC checks.

Password security

  • bcrypt hashing via utils/password.js
  • Comparison uses bcrypt's constant-time compare
  • Password reset flow is handled in users.js route and tokenService.js

Audit logging

Two audit logging systems operate in parallel:

Nuxt server audit logger (server/utils/audit-logger.ts):

  • Appends JSONL events to logs/audit.jsonl
  • On Azure SWA where the filesystem is read-only, falls back to console.error('[AUDIT]', ...) so Azure Monitor captures the events
  • Records: unauthorized_access, forbidden_access with timestamp, IP, resource, method, result, and user details

Backend audit log (routes/audit.js):

  • Persists audit records to the audit_log database table
  • RBAC-protected read and write endpoints
  • The frontend exposes audit log views in the admin area

QC-enforced security rules

The frontend QC pipeline blocks commits that violate these security rules:

  • No raw readBody() — all server mutation routes must call validateBody(event, schema) with a Zod schema
  • No inline z.object({}) schemas — define in shared/schemas.ts, import in route
  • No SQL injection — all backend queries must use $1/$2 parameterized placeholders; never string-concatenate user input into SQL
  • entity_type = 'employee' strict filtering — personnel queries on transmitters must not use entity_type IS NULL (historically caused 110+ ambient devices to appear as personnel)
  • No hardcoded secrets — API keys, connection strings, and Bearer tokens are blocked from committed files
  • No direct DB access from Nuxt routes — Nuxt server routes must proxy to the backend; only server/utils/database-validation.ts and server/api/health/ are exempt
  • Migration safety — all CREATE TABLE / CREATE INDEX must use IF NOT EXISTS; all DROP must use IF EXISTS

NISC Muster Tracking Documentation