Last updated January 2025 (version-5)
Cross-Origin Resource Sharing (CORS) is a security mechanism that allows your Lightning Server application to safely handle requests from web applications hosted on different domains. Without CORS configuration, browsers will block cross-origin requests by default.
Lightning Server provides a CorsInterceptor that handles all CORS logic, including:
- Origin validation with flexible pattern matching
- Automatic preflight (OPTIONS) request handling
- CORS header injection
- WebSocket connection validation
The simplest way to enable CORS is to add the CorsInterceptor to your server:
object Server : ServerBuilder() {
val corsSettings = setting("cors", CorsSettings(
limitToDomains = listOf("https://example.com"),
limitToHeaders = listOf("Content-Type", "Authorization"),
limitToMethods = listOf("GET", "POST", "PUT", "DELETE"),
allowCredentials = true
))
init {
interceptors.add(CorsInterceptor(corsSettings))
}
val myEndpoint = path.path("api").get bind HttpHandler {
HttpResponse.plainText("Hello from API!")
}
}Controls which origins are allowed to make cross-origin requests.
// Allow specific origins
limitToDomains = listOf("https://example.com", "https://app.example.com")
// Allow all subdomains
limitToDomains = listOf("https://*.example.com")
// Allow any scheme (http or https)
limitToDomains = listOf("*.example.com")
// Allow all origins (development only!)
limitToDomains = listOf("*")Pattern Matching:
https://example.com- Exact match with scheme*.example.com- Matches any subdomain with any schemehttps://*.example.com- Matches any subdomain with HTTPS only*- Matches all origins (not recommended for production)
Controls which HTTP headers can be sent in requests.
// Allow specific headers
limitToHeaders = listOf("Content-Type", "Authorization", "X-Api-Key")
// Mirror all requested headers (permissive)
limitToHeaders = listOf("*")
// Block additional headers
limitToHeaders = emptyList()Controls which HTTP methods are allowed for cross-origin requests.
// Allow specific methods
limitToMethods = listOf("GET", "POST", "PUT", "DELETE")
// Mirror requested methods (permissive)
limitToMethods = listOf("*")
// Only allow safe methods
limitToMethods = listOf("GET", "HEAD")Specifies which response headers should be exposed to the client JavaScript.
// Expose custom headers
exposedHeaders = listOf("X-Total-Count", "X-Page-Number")
// Don't expose any additional headers
exposedHeaders = emptyList()By default, browsers only expose "CORS-safe" response headers. Use this to expose additional headers.
Controls whether the browser should include credentials (cookies, authorization headers) in requests.
// Allow credentials
allowCredentials = true
// Block credentials
allowCredentials = falseWarning: When allowCredentials = true, you cannot use wildcard (*) for limitToDomains. You must specify exact
origins or patterns. <!-TODO-------------------------->
How long (in seconds) browsers should cache preflight responses.
// Cache for 1 hour
cacheLength = 3600u
// Cache for 24 hours
cacheLength = 86400u
// Don't send cache header
cacheLength = nullControls what happens when a request has a non-matching origin.
// Block disallowed origins with 403 Forbidden
forbidOnMatchFail = true
// Allow requests but omit CORS headers
forbidOnMatchFail = falseWhen false, the request proceeds normally but without CORS headers, meaning the browser will block the response. This
can be useful to avoid revealing endpoint existence.
Note: WebSocket connections always fail on origin mismatch, regardless of this setting.
val corsSettings = setting("cors", CorsSettings.forDevelopment())This uses the built-in forDevelopment preset that mirrors all origins, headers, and methods. Never use this in
production.
val corsSettings = setting("cors", CorsSettings(
limitToDomains = listOf("https://app.example.com"),
limitToHeaders = listOf("Content-Type", "Authorization"),
limitToMethods = listOf("GET", "POST", "PUT", "DELETE"),
allowCredentials = true,
cacheLength = 3600u,
forbidOnMatchFail = true
))val corsSettings = setting("cors", CorsSettings(
limitToDomains = listOf("https://*.example.com"),
limitToHeaders = listOf("Content-Type", "Authorization"),
limitToMethods = listOf("GET", "POST", "PUT", "DELETE", "PATCH"),
allowCredentials = true,
cacheLength = 86400u,
forbidOnMatchFail = true
))When a browser makes a "complex" cross-origin request (with custom headers, methods other than GET/POST, etc.), it first sends a preflight OPTIONS request.
The CorsInterceptor automatically handles preflight requests:
- Discovers which HTTP methods are actually defined for the requested path
- Filters methods based on
limitToMethodsif configured - Returns a 204 No Content response with appropriate CORS headers
- Returns 404 if no methods are defined for that path
Example preflight flow:
Browser → OPTIONS /api/users
Origin: https://example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type
Server → 204 No Content
Access-Control-Allow-Origin: https://example.com
Access-Control-Allow-Methods: GET,POST,PUT,DELETE
Access-Control-Allow-Headers: Content-Type
Access-Control-Max-Age: 3600
Access-Control-Allow-Credentials: true
Then the actual request:
Browser → POST /api/users
Origin: https://example.com
Content-Type: application/json
Server → 200 OK
Access-Control-Allow-Origin: https://example.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: X-Total-Count
WebSocket connections are also protected by CORS:
val wsEndpoint = path.path("chat") bind WebSocketHandler(
storageSerializer = Unit.serializer(),
willConnect = { Unit },
messageFromClient = { frame ->
send(frame) // Echo
}
)The CorsInterceptor validates the Origin header during the WebSocket handshake. If the origin doesn't match, the
connection is rejected with 403 Forbidden.
Important: WebSocket connections always enforce origin checking, even if forbidOnMatchFail = false. This is
because browsers themselves do not verify CORS for WebSocket connections.
CORS settings can be configured via your settings.json file:
{
"cors": {
"limitToDomains": ["https://app.example.com", "https://*.trusted.com"],
"limitToHeaders": ["Content-Type", "Authorization"],
"limitToMethods": ["GET", "POST", "PUT", "DELETE"],
"exposedHeaders": ["X-Total-Count"],
"allowCredentials": true,
"cacheLength": 3600,
"forbidOnMatchFail": true
}
}You can test CORS behavior using curl:
# Test a regular request with Origin
curl -H "Origin: https://example.com" \
http://localhost:8080/api/data
# Test a preflight request
curl -X OPTIONS \
-H "Origin: https://example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type" \
http://localhost:8080/api/dataIn your tests, use the test utilities:
@Test
fun testCors() {
TestServer.test(
settings = { generalSettings set GeneralServerSettings() }
) {
runBlocking {
val response = myEndpoint.test(
headers = HttpHeaders {
set(HttpHeader.Origin, "https://example.com")
}
)
assertEquals("https://example.com",
response.headers[HttpHeader.AccessControlAllowOrigin]?.root)
}
}
}// BAD - allows any origin
limitToDomains = listOf("*")
// GOOD - specific origins only
limitToDomains = listOf("https://app.example.com")When allowCredentials = true, browsers include cookies and authorization headers. Make sure:
- You specify exact origins (no wildcards)
- You validate authentication properly
- You use HTTPS in production
Only expose headers that the client actually needs:
// Only expose what's necessary
exposedHeaders = listOf("X-Total-Count")
// Don't expose sensitive information
// exposedHeaders = listOf("X-Internal-Id", "X-Db-Query-Time") // BADforbidOnMatchFail = true // Reject invalid origins immediatelyThis prevents your API from processing requests from unknown origins.
// This allows ANY subdomain
limitToDomains = listOf("https://*.example.com")Make sure you control all subdomains, or attackers could register malicious subdomains.
This usually means the preflight request failed. Check that your limitToHeaders and limitToMethods include what the
browser is requesting.
Postman doesn't enforce CORS. Browsers do. Make sure your origin is in limitToDomains.
Check that:
allowCredentials = truein your CORS settings- Your frontend code uses
credentials: 'include'(fetch) orwithCredentials: true(axios) - Your origin is exact (not a wildcard)
WebSocket connections require matching origins. Make sure the origin matches one of your limitToDomains patterns.
This means no endpoint is defined for that path. Check your routing configuration.
- Endpoints - How to define HTTP endpoints
- WebSockets - WebSocket configuration
- Settings - Server settings management
- Authentication - User authentication
- Package:
com.lightningkite.lightningserver.cors - Classes:
CorsInterceptor,CorsSettings - MDN CORS Documentation