Security
Overview
Security is enforced at two layers in this stack:
- Nuxt server layer — Nitro middleware that runs on every request before it reaches a route handler or the backend proxy
- 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:
| Order | File | Purpose |
|---|---|---|
| 1 | security-headers.ts | Sets CSP, HSTS, X-Frame-Options, etc. |
| 2 | rate-limit.ts | Blocks brute-force login attempts |
| 3 | csrf.ts | Double-submit cookie CSRF protection |
| 4 | api-auth.ts | JWT validation and admin role enforcement |
| 5 | okta-session.ts | Okta OIDC session → backend JWT exchange |
Security headers (security-headers.ts)
Applied to every response. Configured differently for development and production:
| Header | Dev | Production |
|---|---|---|
Content-Security-Policy | Permissive — unsafe-eval for Vue HMR, localhost:* connect sources | Tightened — no unsafe-eval, no localhost URLs |
Strict-Transport-Security | Not set | max-age=31536000; includeSubDomains |
X-Content-Type-Options | nosniff | nosniff |
X-Frame-Options | DENY | DENY |
Referrer-Policy | strict-origin-when-cross-origin | strict-origin-when-cross-origin |
Permissions-Policy | geolocation=(self), microphone=(), camera=(), payment=() | Same |
X-Powered-By | Removed | Removed |
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
Mapwith 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,DELETEto/api/* - Skip list:
/api/auth/login,/api/auth/backend-login,/api/auth/csrf-token,/api/health - Mechanism: The
csrf_tokencookie value must match theX-CSRF-Tokenrequest header — a browser-controlled cross-origin request cannot read the cookie value to replay it - Token generation:
GET /api/auth/csrf-tokensets the cookie and returns the token value; the frontend includes it on all mutations viauseApi
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-loggerAuth cookie architecture
| Cookie | httpOnly | Purpose |
|---|---|---|
auth_token | Yes | Signed JWT — readable only by the Nitro server; never accessible to client JS |
auth_user | No | JSON with userId, email, displayName, role — used by client for UI state |
auth_permissions | No | JSON array of permission strings — used by client for UI gating |
auth_token_expiry | No | Millisecond 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: Bearerwhen proxying to the backend - Client-side auth state is driven by
auth_userandauth_token_expirycookies 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:
| Format | Parts | Encoding | Expiry unit |
|---|---|---|---|
| Standard JWT | 3 (header.payload.signature) | base64url | Seconds since epoch |
| Backend legacy | 2 (data.signature) | base64 + hex HMAC | Milliseconds 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 transparentlyA 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/:
| File | Type | Purpose |
|---|---|---|
auth.ts | Named | Redirects unauthenticated users to /login (or OIDC /auth/login) |
device.global.ts | Global | Switches default ↔ mobile layout on every navigation based on viewport |
permissions.ts | Named | Fine-grained RBAC; used with permissions:[] or permissionsAny:[] in definePageMeta |
Using auth and permissions together
<script setup>
definePageMeta({
middleware: ['auth', 'permissions'],
// Strict mode — user must have ALL listed permissions
permissions: ['personnel.read', 'personnel.create']
})
</script><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/)
| File | Purpose |
|---|---|
auth.js | Decodes JWT and attaches user to request context |
rbac.js | requirePermission(resource, action) — evaluates loaded role/permission set |
rateLimit.js | Configurable per-route rate limiting on the Functions host |
performance.js | Request timing and logging |
utils/withAuth.js | withAuth 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.jsroute andtokenService.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_accesswith timestamp, IP, resource, method, result, and user details
Backend audit log (routes/audit.js):
- Persists audit records to the
audit_logdatabase 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 callvalidateBody(event, schema)with a Zod schema - No inline
z.object({})schemas — define inshared/schemas.ts, import in route - No SQL injection — all backend queries must use
$1/$2parameterized placeholders; never string-concatenate user input into SQL entity_type = 'employee'strict filtering — personnel queries ontransmittersmust not useentity_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.tsandserver/api/health/are exempt - Migration safety — all
CREATE TABLE/CREATE INDEXmust useIF NOT EXISTS; allDROPmust useIF EXISTS