{
  "topic": "config.cors",
  "path": [
    "config",
    "cors"
  ],
  "title": "CORS configuration",
  "synopsis": "config.cors — Cross-Origin Resource Sharing (CORS) controls for the public HTTP surface.",
  "body": "# config.cors\n\n## NAME\n\nconfig.cors — Cross-Origin Resource Sharing (CORS) controls for the public HTTP surface.\n\n## SYNOPSIS\n\ncyoda supports four CORS modes: `disabled`, `loopback` (default), `wildcard`, and `allowlist`.\nConfigure the mode via `CYODA_CORS_ENABLED` and `CYODA_CORS_ALLOWED_ORIGINS`.\n\n## OPTIONS\n\n- `CYODA_CORS_ENABLED` — enable CORS middleware; set to `false` to disable and handle CORS at\n  an upstream ingress/proxy layer (default: `true`)\n- `CYODA_CORS_ALLOWED_ORIGINS` — comma-separated list of allowed origins, or `*` for wildcard\n  mode (default: empty — loopback mode)\n\n## MODES\n\nThe effective mode is determined by the combination of the two env vars:\n\n- `CYODA_CORS_ENABLED=false` — **disabled** (regardless of `CYODA_CORS_ALLOWED_ORIGINS`)\n- `CYODA_CORS_ENABLED=true`, `CYODA_CORS_ALLOWED_ORIGINS` empty — **loopback** (default)\n- `CYODA_CORS_ENABLED=true`, `CYODA_CORS_ALLOWED_ORIGINS=*` — **wildcard**\n- `CYODA_CORS_ENABLED=true`, `CYODA_CORS_ALLOWED_ORIGINS=https://example.com,...` — **allowlist**\n\n### disabled\n\nCORS middleware is not installed. No `Access-Control-*` headers are emitted. OPTIONS requests\nreturn chi's default 405. Use this when CORS is handled at your ingress layer (nginx, Envoy,\ncloud load balancer).\n\n### loopback (default)\n\nOnly loopback origins are permitted: `http(s)://localhost`, `http(s)://127.0.0.1`, and\n`http(s)://[::1]` on any port. Suitable for local development. Set\n`CYODA_CORS_ALLOWED_ORIGINS` to permit additional origins.\n\n### wildcard\n\n`Access-Control-Allow-Origin: *` is emitted for all cross-origin requests. Credentials\n(cookies, `Authorization` header) cannot be used with wildcard mode. Appropriate only for\nfully public, stateless read APIs.\n\n### allowlist\n\nOnly the origins listed in `CYODA_CORS_ALLOWED_ORIGINS` are permitted. Exact scheme+host+port\nmatching (no wildcards in individual entries). Origins must be absolute URIs with scheme and\nhost; paths and query strings are not permitted.\n\n## BEHAVIOUR\n\nThe following headers are emitted by the CORS middleware when it is installed\n(`CYODA_CORS_ENABLED=true`):\n\n**On every response from the installed middleware (preflight, CORS request, or no-`Origin` pass-through):**\n\n- `Vary: Origin` — always appended (never overwrites an existing `Vary` value).\n  This instructs intermediate caches to key by `Origin` so that a mode change\n  does not cause a stale no-`Origin` response to be served to an `Origin`-bearing\n  request.\n\n**Access-Control-Allow-Origin:**\n\n- loopback mode: the matched origin is echoed literally; omitted if no match.\n- allowlist mode: the matched origin is echoed literally; omitted if no match.\n- wildcard mode: literal `*` for every request, never reflective of `Origin`.\n- disabled mode: not emitted.\n\n**On preflight responses only** (`OPTIONS` with `Origin` and\n`Access-Control-Request-Method`):\n\n- `Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS` (static)\n- `Access-Control-Allow-Headers: Authorization, Content-Type, traceparent, tracestate` (static)\n- `Access-Control-Max-Age: 86400` (static)\n\nThese three headers are emitted on every preflight regardless of whether\nthe origin matched the policy. Only `Access-Control-Allow-Origin` is\nomitted when the origin is rejected — a deployer debugging an allowlist\nmiss will see the static three present alongside the absent ACAO.\n\n**`Access-Control-Allow-Credentials` is NOT emitted in v1.** Authentication is\nbearer-in-`Authorization`; cookies and HTTP-auth are not used. Credentials mode\nadds attack surface without functional benefit for this auth model.\n\n## TENANT ISOLATION\n\nCORS is a browser-side defence against unauthorized cross-origin reads. It is\n**not** a tenant-isolation control. JWT claims and per-request authorization\nchecks in the data path enforce tenant boundaries. An allowlisted SPA serving\nmultiple tenants relies entirely on the auth layer, not CORS, to prevent\ncross-tenant access. No CORS rule substitutes for or displaces JWT-based authz.\n\n## DEPLOYMENT\n\n**Local dev / docker compose**\n\nNo configuration needed. Loopback mode allows `http://localhost`, `http://127.0.0.1`,\nand `http://[::1]` on any port by default. Suitable for Vite/webpack dev servers\nand local docker-compose SPAs.\n\n**Behind an ingress that handles its own CORS**\n\nSet `CYODA_CORS_ENABLED=false` and configure CORS at the ingress (nginx, Envoy,\ncloud load balancer). Do not let both the ingress and cyoda-go emit\n`Access-Control-Allow-Origin`: a browser receiving two `Access-Control-Allow-Origin`\nvalues will reject the response.\n\n**Behind a reverse proxy with no CORS handling**\n\nSet `CYODA_CORS_ALLOWED_ORIGINS=https://your.spa.host`. The proxy forwards\nrequests unchanged; cyoda-go's allowlist middleware emits the correct header\nfor the matching origin.\n\n## PNA AND CSRF\n\n**Private Network Access (PNA):** cyoda-go does not handle\n`Access-Control-Request-Private-Network` / `Access-Control-Allow-Private-Network`.\nDeployers needing browsers on a public origin to reach cyoda-go on a private\nnetwork should configure PNA at the ingress.\n\n**CSRF:** CSRF is not a threat for bearer-in-header authentication. The SPA\nexplicitly attaches the bearer on each request rather than relying on ambient\ncredentials. No anti-CSRF token is required or provided.\n\n## TOGGLING CORS_ENABLED\n\nToggling `CYODA_CORS_ENABLED` between `true` and `false` requires a\ndownstream-cache flush. Responses cached during the disabled period lack\n`Vary: Origin` and could be served to origins for which the post-toggle policy\ndisagrees.\n\n## TROUBLESHOOTING\n\n- **Browser logs a CORS error but the service logs the request as `200`** —\n  the origin was rejected by the allowlist. The middleware omits\n  `Access-Control-Allow-Origin` and the browser blocks reading the body. Add\n  the origin to `CYODA_CORS_ALLOWED_ORIGINS` with exact scheme+host+port.\n\n- **Multi-valued `Access-Control-Allow-Origin`** — both the ingress and\n  cyoda-go are emitting the header. Set `CYODA_CORS_ENABLED=false` and handle\n  CORS entirely at the ingress.\n\n- **Startup failure: `cors: origin \"...\" has non-ASCII host; convert to punycode`**\n  — IDN host names must be supplied in punycode form (`xn--...`).\n\n- **Startup failure: default port rejected** — drop the port from the origin:\n  use `https://example.com`, not `https://example.com:443`; use `http://example.com`,\n  not `http://example.com:80`.\n\n- **Startup WARN about wildcard mode** — if wildcard is unintended, set a\n  specific allowlist with `CYODA_CORS_ALLOWED_ORIGINS=https://your.app.host`.\n\n- **Local SPA on `file://` cannot reach cyoda-go** — `file://` produces\n  `Origin: null`, which is not auto-allowed in any mode (in wildcard mode,\n  `null` receives `Access-Control-Allow-Origin: *`, which browsers honour for\n  non-credentialed requests). Serve the SPA via a local HTTP server (e.g.\n  `python3 -m http.server`) so a normal `http://localhost` origin is used instead.\n\n## EXAMPLES\n\n**Loopback only (local dev, default):**\n\n```\n# nothing to set — loopback is the default when CYODA_CORS_ENABLED=true\n```\n\n**Single production origin:**\n\n```\nCYODA_CORS_ENABLED=true\nCYODA_CORS_ALLOWED_ORIGINS=https://app.example.com\n```\n\n**Multiple origins:**\n\n```\nCYODA_CORS_ENABLED=true\nCYODA_CORS_ALLOWED_ORIGINS=https://app.example.com,https://admin.example.com\n```\n\n**Wildcard (public read API):**\n\n```\nCYODA_CORS_ENABLED=true\nCYODA_CORS_ALLOWED_ORIGINS=*\n```\n\n**Disabled (CORS at ingress):**\n\n```\nCYODA_CORS_ENABLED=false\n```\n\n## SEE ALSO\n\n- config\n- run\n",
  "sections": [
    {
      "name": "NAME",
      "body": "config.cors — Cross-Origin Resource Sharing (CORS) controls for the public HTTP surface."
    },
    {
      "name": "SYNOPSIS",
      "body": "cyoda supports four CORS modes: `disabled`, `loopback` (default), `wildcard`, and `allowlist`.\nConfigure the mode via `CYODA_CORS_ENABLED` and `CYODA_CORS_ALLOWED_ORIGINS`."
    },
    {
      "name": "OPTIONS",
      "body": "- `CYODA_CORS_ENABLED` — enable CORS middleware; set to `false` to disable and handle CORS at\n  an upstream ingress/proxy layer (default: `true`)\n- `CYODA_CORS_ALLOWED_ORIGINS` — comma-separated list of allowed origins, or `*` for wildcard\n  mode (default: empty — loopback mode)"
    },
    {
      "name": "MODES",
      "body": "The effective mode is determined by the combination of the two env vars:\n\n- `CYODA_CORS_ENABLED=false` — **disabled** (regardless of `CYODA_CORS_ALLOWED_ORIGINS`)\n- `CYODA_CORS_ENABLED=true`, `CYODA_CORS_ALLOWED_ORIGINS` empty — **loopback** (default)\n- `CYODA_CORS_ENABLED=true`, `CYODA_CORS_ALLOWED_ORIGINS=*` — **wildcard**\n- `CYODA_CORS_ENABLED=true`, `CYODA_CORS_ALLOWED_ORIGINS=https://example.com,...` — **allowlist**\n\n### disabled\n\nCORS middleware is not installed. No `Access-Control-*` headers are emitted. OPTIONS requests\nreturn chi's default 405. Use this when CORS is handled at your ingress layer (nginx, Envoy,\ncloud load balancer).\n\n### loopback (default)\n\nOnly loopback origins are permitted: `http(s)://localhost`, `http(s)://127.0.0.1`, and\n`http(s)://[::1]` on any port. Suitable for local development. Set\n`CYODA_CORS_ALLOWED_ORIGINS` to permit additional origins.\n\n### wildcard\n\n`Access-Control-Allow-Origin: *` is emitted for all cross-origin requests. Credentials\n(cookies, `Authorization` header) cannot be used with wildcard mode. Appropriate only for\nfully public, stateless read APIs.\n\n### allowlist\n\nOnly the origins listed in `CYODA_CORS_ALLOWED_ORIGINS` are permitted. Exact scheme+host+port\nmatching (no wildcards in individual entries). Origins must be absolute URIs with scheme and\nhost; paths and query strings are not permitted."
    },
    {
      "name": "BEHAVIOUR",
      "body": "The following headers are emitted by the CORS middleware when it is installed\n(`CYODA_CORS_ENABLED=true`):\n\n**On every response from the installed middleware (preflight, CORS request, or no-`Origin` pass-through):**\n\n- `Vary: Origin` — always appended (never overwrites an existing `Vary` value).\n  This instructs intermediate caches to key by `Origin` so that a mode change\n  does not cause a stale no-`Origin` response to be served to an `Origin`-bearing\n  request.\n\n**Access-Control-Allow-Origin:**\n\n- loopback mode: the matched origin is echoed literally; omitted if no match.\n- allowlist mode: the matched origin is echoed literally; omitted if no match.\n- wildcard mode: literal `*` for every request, never reflective of `Origin`.\n- disabled mode: not emitted.\n\n**On preflight responses only** (`OPTIONS` with `Origin` and\n`Access-Control-Request-Method`):\n\n- `Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS` (static)\n- `Access-Control-Allow-Headers: Authorization, Content-Type, traceparent, tracestate` (static)\n- `Access-Control-Max-Age: 86400` (static)\n\nThese three headers are emitted on every preflight regardless of whether\nthe origin matched the policy. Only `Access-Control-Allow-Origin` is\nomitted when the origin is rejected — a deployer debugging an allowlist\nmiss will see the static three present alongside the absent ACAO.\n\n**`Access-Control-Allow-Credentials` is NOT emitted in v1.** Authentication is\nbearer-in-`Authorization`; cookies and HTTP-auth are not used. Credentials mode\nadds attack surface without functional benefit for this auth model."
    },
    {
      "name": "TENANT ISOLATION",
      "body": "CORS is a browser-side defence against unauthorized cross-origin reads. It is\n**not** a tenant-isolation control. JWT claims and per-request authorization\nchecks in the data path enforce tenant boundaries. An allowlisted SPA serving\nmultiple tenants relies entirely on the auth layer, not CORS, to prevent\ncross-tenant access. No CORS rule substitutes for or displaces JWT-based authz."
    },
    {
      "name": "DEPLOYMENT",
      "body": "**Local dev / docker compose**\n\nNo configuration needed. Loopback mode allows `http://localhost`, `http://127.0.0.1`,\nand `http://[::1]` on any port by default. Suitable for Vite/webpack dev servers\nand local docker-compose SPAs.\n\n**Behind an ingress that handles its own CORS**\n\nSet `CYODA_CORS_ENABLED=false` and configure CORS at the ingress (nginx, Envoy,\ncloud load balancer). Do not let both the ingress and cyoda-go emit\n`Access-Control-Allow-Origin`: a browser receiving two `Access-Control-Allow-Origin`\nvalues will reject the response.\n\n**Behind a reverse proxy with no CORS handling**\n\nSet `CYODA_CORS_ALLOWED_ORIGINS=https://your.spa.host`. The proxy forwards\nrequests unchanged; cyoda-go's allowlist middleware emits the correct header\nfor the matching origin."
    },
    {
      "name": "PNA AND CSRF",
      "body": "**Private Network Access (PNA):** cyoda-go does not handle\n`Access-Control-Request-Private-Network` / `Access-Control-Allow-Private-Network`.\nDeployers needing browsers on a public origin to reach cyoda-go on a private\nnetwork should configure PNA at the ingress.\n\n**CSRF:** CSRF is not a threat for bearer-in-header authentication. The SPA\nexplicitly attaches the bearer on each request rather than relying on ambient\ncredentials. No anti-CSRF token is required or provided."
    },
    {
      "name": "TOGGLING CORS_ENABLED",
      "body": "Toggling `CYODA_CORS_ENABLED` between `true` and `false` requires a\ndownstream-cache flush. Responses cached during the disabled period lack\n`Vary: Origin` and could be served to origins for which the post-toggle policy\ndisagrees."
    },
    {
      "name": "TROUBLESHOOTING",
      "body": "- **Browser logs a CORS error but the service logs the request as `200`** —\n  the origin was rejected by the allowlist. The middleware omits\n  `Access-Control-Allow-Origin` and the browser blocks reading the body. Add\n  the origin to `CYODA_CORS_ALLOWED_ORIGINS` with exact scheme+host+port.\n\n- **Multi-valued `Access-Control-Allow-Origin`** — both the ingress and\n  cyoda-go are emitting the header. Set `CYODA_CORS_ENABLED=false` and handle\n  CORS entirely at the ingress.\n\n- **Startup failure: `cors: origin \"...\" has non-ASCII host; convert to punycode`**\n  — IDN host names must be supplied in punycode form (`xn--...`).\n\n- **Startup failure: default port rejected** — drop the port from the origin:\n  use `https://example.com`, not `https://example.com:443`; use `http://example.com`,\n  not `http://example.com:80`.\n\n- **Startup WARN about wildcard mode** — if wildcard is unintended, set a\n  specific allowlist with `CYODA_CORS_ALLOWED_ORIGINS=https://your.app.host`.\n\n- **Local SPA on `file://` cannot reach cyoda-go** — `file://` produces\n  `Origin: null`, which is not auto-allowed in any mode (in wildcard mode,\n  `null` receives `Access-Control-Allow-Origin: *`, which browsers honour for\n  non-credentialed requests). Serve the SPA via a local HTTP server (e.g.\n  `python3 -m http.server`) so a normal `http://localhost` origin is used instead."
    },
    {
      "name": "EXAMPLES",
      "body": "**Loopback only (local dev, default):**\n\n```\n# nothing to set — loopback is the default when CYODA_CORS_ENABLED=true\n```\n\n**Single production origin:**\n\n```\nCYODA_CORS_ENABLED=true\nCYODA_CORS_ALLOWED_ORIGINS=https://app.example.com\n```\n\n**Multiple origins:**\n\n```\nCYODA_CORS_ENABLED=true\nCYODA_CORS_ALLOWED_ORIGINS=https://app.example.com,https://admin.example.com\n```\n\n**Wildcard (public read API):**\n\n```\nCYODA_CORS_ENABLED=true\nCYODA_CORS_ALLOWED_ORIGINS=*\n```\n\n**Disabled (CORS at ingress):**\n\n```\nCYODA_CORS_ENABLED=false\n```"
    },
    {
      "name": "SEE ALSO",
      "body": "- config\n- run"
    }
  ],
  "see_also": [
    "config",
    "run"
  ],
  "stability": "stable",
  "actions": []
}
