A Ktor endpoint returns responses without the CORS headers you configured. How do you diagnose it?
answer
- Two causes: never ran, or ran and declined
- Reproduce it without a browser first
- Preflight is a separate request
- Deny-by-default: scheme, method, headers all explicit
- The wildcard is a diagnostic, not a fix
basics
~20 sCheck three things in order: that CORS is installed on a pipeline the request actually passes through, that the configuration matches the browser's origin, method and requested headers exactly, and that nothing responded and finished the pipeline before the plugin ran.
solid answer
~50 sStart by separating "the plugin never ran" from "the plugin ran and declined." Confirm the request reaches the scope where `install(CORS)` happened — a plugin installed on one route subtree does not cover another, and an error path that short-circuits the pipeline early can produce a response the plugin never touched. Then check the configuration against what the browser actually sent: `allowHost` must match origin **and** scheme, every non-simple request header the client sends needs `allowHeader`, and non-default methods need `allowMethod`. The preflight is its own request — an `OPTIONS` with `Access-Control-Request-Method` — so inspect it separately with curl rather than trusting the browser console's summary. Finally, remember the browser enforces CORS, not the server: a response missing the headers usually means the server declined, so the fix is configuration, never disabling checks in the client.
code
kotlin · 8 linesinstall(CORS) {
allowHost("app.example.com", schemes = listOf("https"))
allowMethod(HttpMethod.Put)
allowMethod(HttpMethod.Delete)
allowHeader(HttpHeaders.ContentType)
allowHeader("X-Request-Id")
allowCredentials = true
}go deeper
Recall that CORS is a plugin you install explicitly and that the browser blocks the response when the server does not permit the origin. Know the policy is deny-by-default.
Explain the preflight exchange, why a JSON POST triggers one, and which parts of the configuration correspond to the origin, method and headers the browser asks for.
Diagnose systematically: reproduce outside the browser, separate 'never ran' from 'declined', check install scope and earlier short-circuits, and inspect the edge before changing configuration.
Treat the allowed-origin list as a governed security boundary with a review path, so no incident is closed by widening it, and make the policy consistent across services rather than per-team folklore.
## Frame the failure correctly "CORS headers are missing" has exactly two causes: the CORS plugin did not run for this call, or it ran and decided the request did not qualify. Those need different fixes, so establish which one you are looking at before changing configuration. The fastest way to separate them is to bypass the browser. Issue the preflight yourself: ``` curl -i -X OPTIONS https://api.example.com/orders \ -H "Origin: https://app.example.com" \ -H "Access-Control-Request-Method: POST" \ -H "Access-Control-Request-Headers: content-type, x-request-id" ``` A response with no `Access-Control-Allow-Origin` at all tells you the server declined or never evaluated the policy. The browser console will only tell you the request was blocked, which conflates both cases. ## Cause 1 — the plugin never saw the call **Wrong scope.** If CORS was installed inside a `route` block, only that subtree is covered. Endpoints outside it get nothing. Application-wide policy belongs in the module function, installed on the `Application`. **The pipeline finished earlier.** An interceptor or plugin that responds and ends the pipeline before the CORS plugin's phase means later interceptors never run. Any early-rejection logic you added — a body-size guard, a custom auth filter — is a candidate. Check what else is installed and where it sits relative to CORS. **Errors take a different path.** A response produced by exception handling can be assembled outside the flow you expect, so verify with a *successful* request first. If the headers appear on 200s and vanish on 500s, you have narrowed it to the error path, and the fix is to make sure error responses still traverse the plugin. ## Cause 2 — the plugin ran and declined Ktor's CORS plugin is deny-by-default; every allowance is explicit. - **Host and scheme.** `allowHost("app.example.com", schemes = listOf("https"))` — passing a bare host without the scheme the browser actually uses is the single most common mistake. The value must match the browser's `Origin`, which includes scheme and port. A non-default port must be part of the host you allow. - **Headers.** Only a small set of request headers are "simple"; anything else — a custom correlation header, and `Content-Type` when it is `application/json` — must be permitted with `allowHeader`. The preflight's `Access-Control-Request-Headers` lists exactly what the browser wants; compare it line by line with your configuration. - **Methods.** `GET`, `HEAD` and `POST` are the defaults; `PUT`, `PATCH` and `DELETE` need `allowMethod`. - **Credentials.** If the browser sends cookies, the policy must allow credentials, and a wildcard origin is not usable together with credentials — that combination is rejected by browsers regardless of what the server sends. - **Exposed headers.** Response headers your JavaScript needs to *read* must be exposed explicitly; the browser hides everything else even on an allowed response. `anyHost()` exists and will make the error disappear, which is exactly why it is dangerous: it is a diagnostic, not a fix. Use it once to confirm the plugin is running and reachable, then replace it with the explicit host list. Shipping it means any origin can invoke your API from a browser with the user's context. ## Cause 3 — you are debugging the wrong hop In production the request usually passes a reverse proxy, CDN or API gateway. Any of them can strip response headers, answer `OPTIONS` itself, or add its own CORS headers that conflict — and duplicate `Access-Control-Allow-Origin` headers are themselves invalid. Curl the origin server directly and then through the edge; if the two differ, the problem is not in Ktor at all. ## The judgment part Two statements separate a senior answer. First: **CORS is enforced by the browser, not the server.** The server merely states a policy. So "disable CORS" in a client, or a browser flag, proves nothing and fixes nothing — and a non-browser client was never restricted in the first place. If a teammate proposes turning off checks in the browser, that is the moment to push back. Second: **the policy is a security boundary.** The list of allowed origins is the set of web applications permitted to drive your API with the user's cookies. Widening it to end an outage is a real reduction in the system's security posture, and it deserves to be treated as such rather than as a build fix. ## Order of operations, condensed 1. Reproduce the preflight and the actual request with curl, against the origin server. 2. Confirm the install scope covers the route, and that nothing responds earlier in the pipeline. 3. Diff the browser's requested origin, method and headers against the configuration. 4. Check the edge for stripped or duplicated headers. 5. Fix by narrowing configuration to what is genuinely needed — never by allowing every host.
- Why is allowing any origin an unacceptable production fix even though it makes the error stop?The allowed-origin list is the set of web applications a browser will let drive your API carrying the user's session. Widening it to everything means any page a user visits can issue those calls in their context. It ends the symptom by removing the control that produced it.
- How do you tell whether a reverse proxy rather than Ktor is responsible?Send the same request directly to the origin server and through the edge, and compare responses. If the origin returns the CORS headers and the edge does not — or the edge adds a second, conflicting Access-Control-Allow-Origin — the policy is being rewritten in front of the application and the fix belongs there.
- A GET works from the browser but a POST with a JSON body is blocked. What is the likely cause?The JSON content type makes the request non-simple, so the browser sends a preflight and requires that content-type be an allowed header, and that POST with that header be permitted. A configuration written only for simple GETs passes the first case and fails the second.
saying these in an interview costs you the question
- Suggests disabling CORS checks in the browser as the fix
- Ships anyHost() as the permanent configuration
- Forgets the origin includes scheme and port, not just hostname
- Believes the server enforces CORS and blocks the request itself
- Ignores the preflight and only inspects the main request