Skip to content

CORS Configuration Guide

CORS (Cross-Origin Resource Sharing) configuration for multi-site authentication system.

// All API calls must include credentials
fetch('https://api.gigmanager.local/endpoint', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`
},
credentials: 'include', // Required for cookies
body: JSON.stringify(data)
})
// Fastify CORS plugin
fastify.register(cors, {
origin: 'https://gigmanager.local:4202', // Exact origin (no wildcard with credentials)
credentials: true, // Allow cookies
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
allowedHeaders: ['Content-Type', 'Authorization']
})
// Cookie settings
res.cookie('sessionId', sessionId, {
httpOnly: true, // JavaScript can't read
secure: true, // HTTPS only
sameSite: 'lax', // CSRF protection
domain: '.gigmanager.local', // Works across subdomains
maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days
})
// No credentials needed - headers only
fetch('https://api.gigmanager.local/endpoint', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
'X-Session-ID': sessionId // Only for refresh endpoint
},
body: JSON.stringify(data)
})
// CORS for satellites - allow multiple origins
const allowedOrigins = [
'https://gigmanager.local:4202', // Main site
'https://client1domain.com', // Satellite 1
'https://client2domain.com', // Satellite 2
// Add more as needed
]
fastify.register(cors, {
origin: (origin, callback) => {
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true)
} else {
callback(new Error('Not allowed by CORS'))
}
},
credentials: false, // No cookies for satellites
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Session-ID']
})
const isDevelopment = process.env.NODE_ENV === 'development'
const allowedOrigins = isDevelopment
? [
'https://gigmanager.local:4202',
'https://api.gigmanager.local:4000',
'http://localhost:4202', // Fallback for non-HTTPS testing
'http://localhost:4000'
]
: [
'https://example.com',
'https://api.example.com',
'https://client1domain.com'
]
// Production uses environment variables
const allowedOrigins = process.env.ALLOWED_ORIGINS.split(',')
// ALLOWED_ORIGINS=https://example.com,https://client1domain.com,https://client2domain.com
  • Strict: Cookie never sent cross-site (breaks satellite functionality)
  • Lax: Cookie sent on top-level navigation, not on API calls from other sites (RECOMMENDED)
  • None: Cookie sent everywhere (requires CSRF protection, increasingly blocked)
// Main site - Lax is perfect
sameSite: 'lax' // Prevents CSRF, allows same-site subdomains
// Would need 'none' only if:
// - Main site AND satellites both needed cookies (we don't do this)
// - Embedding in iframes (we don't do this)

Main Site:

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json
Cookie: sessionId=550e8400-e29b-41d4-a716-446655440000
Origin: https://gigmanager.local:4202

Satellite Site:

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json
X-Session-ID: 550e8400-e29b-41d4-a716-446655440000
Origin: https://client1domain.com

Main Site:

Access-Control-Allow-Origin: https://gigmanager.local:4202
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH
Access-Control-Allow-Headers: Content-Type, Authorization
Set-Cookie: sessionId=...; HttpOnly; Secure; SameSite=Lax

Satellite Site:

Access-Control-Allow-Origin: https://client1domain.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH
Access-Control-Allow-Headers: Content-Type, Authorization, X-Session-ID

Browsers send OPTIONS preflight for:

  • Custom headers (X-Session-ID)
  • Methods other than GET/POST
  • Content-Type other than simple values

Server must respond:

// Fastify CORS plugin handles this automatically
// Manual implementation:
fastify.options('*', async (request, reply) => {
reply
.header('Access-Control-Allow-Origin', request.headers.origin)
.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH')
.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Session-ID')
.header('Access-Control-Max-Age', '86400') // Cache preflight for 24 hours
.send()
})

Symptoms: Login succeeds but refresh fails, no cookie in browser Causes:

  • Missing credentials: 'include' in fetch
  • Missing credentials: true in CORS config
  • Origin mismatch (exact match required)
  • Not using HTTPS (Secure flag requires it)
  • SameSite=None without Secure flag

Solution:

  • Verify both client and server settings
  • Check browser dev tools Network tab for Set-Cookie header
  • Ensure HTTPS in development

Symptoms: OPTIONS request fails, actual request never sent Causes:

  • Missing custom header in allowedHeaders
  • Origin not in allowed list
  • Credentials true but origin is wildcard

Solution:

  • Add all custom headers to allowedHeaders array
  • Use exact origin, not ’*’
  • Enable CORS logging to debug

Symptoms: Cookie visible in Application tab but server doesn’t see it Causes:

  • Cookie domain mismatch
  • httpOnly cookie accessed from JavaScript
  • SameSite too strict

Solution:

  • Check cookie domain matches request domain
  • Server should read from req.cookies, not client
  • Use SameSite=Lax not Strict

Symptoms: 401 errors from satellite sites Causes:

  • Trying to use credentials: ‘include’ (don’t do this)
  • sessionId not in localStorage
  • X-Session-ID header not sent

Solution:

  • Remove credentials: ‘include’ for satellites
  • Verify localStorage has sessionId
  • Add X-Session-ID to headers explicitly
Terminal window
# Login and check cookie is set
curl -i -X POST https://api.gigmanager.local:4000/api/login \
-H "Content-Type: application/json" \
-H "Origin: https://gigmanager.local:4202" \
-d '{"email":"test@example.com","password":"pass","source":"main"}' \
--cookie-jar cookies.txt
# Should see Set-Cookie header
# Test authenticated request
curl -i https://api.gigmanager.local:4000/api/data \
-H "Authorization: Bearer {accessToken}" \
-H "Origin: https://gigmanager.local:4202" \
--cookie cookies.txt
# Should include cookie and succeed
Terminal window
# Login from satellite
curl -i -X POST https://api.gigmanager.local:4000/api/login \
-H "Content-Type: application/json" \
-H "Origin: https://client1domain.com" \
-d '{"email":"test@example.com","password":"pass","source":"satellite"}'
# Should return sessionId in body (not cookie)
# Test with headers
curl -i https://api.gigmanager.local:4000/api/data \
-H "Authorization: Bearer {accessToken}" \
-H "X-Session-ID: {sessionId}" \
-H "Origin: https://client1domain.com"
# Should succeed
  • Main site uses credentials: 'include'
  • Satellites do NOT use credentials: 'include'
  • Cookie has httpOnly: true
  • Cookie has secure: true (HTTPS only)
  • Cookie has sameSite: 'lax'
  • CORS origin is exact match (not wildcard when using credentials)
  • Custom headers (X-Session-ID) in allowedHeaders
  • Sensitive operations check for cookie presence
  • Environment-specific allowed origins
  • HTTPS in development matches production behavior