Resolving CORS errors in SvelteKit

🌱March 27, 2024.
Last tended March 30, 2024.
budding 🌿
1 minute read ⏱

Web browsers try to protect the security of their users. One way they do this is by prohibiting scripts on one domain from interacting with resources on another domain by default. Sometimes as a developer, I want users to be able to interact with resources on my site from somewhere else:

  • Embedded media
  • A widget or badge showing their data on another site
  • Calling a public APIs from their browser (client-side)
  • Sending analytics data to a 3rd-party analytics server

Requesting a resource on one site from another is called a “cross-origin resource sharing” (CORS) request. While building Penguinsight (it’s a tool that enables developers to provide feedback on developer documentation) I had just such a need. Imagine this scenario:

a developer is reading the documentation on using an API from one of my customers. One of the steps doesn’t work, and they want to use the Penguinsight widget on the page to provide feedback. That feedback gets sent to the Penguinsight server, which is on a different domain than the documentation site.

The user is requesting to access a resource (my API) cross-origin. Their browser makes a request to the Penguinsight server, but it’s not the POST /api/feedback request made by my tool. Before that happens, the browser needs to make a “preflight request” to make sure the server allows the actual request. This is done by requesting OPTIONS /api/feedback. The browser will only make the POST request if the server tells it that the request is allowed.

const corsHeaders = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Methods': 'OPTIONS,POST',
};

'Access-Control-Allow-Origin' specifies which domains are allowed to originate requests for the resource. The wildcard, '*', is used to denote that any site can request it. For a public API like on Penguinsight, this is the right option.

'Access-Control-Allow-Methods' gives the browser a comma-separated list of the allowed HTTP method verbs. OPTIONS is needed for the preflight request, and I also needed POST for the /api/feedback endpoint. You can add other HTTP methods here such as GET, PUT, or PATCH, but I didn’t need them for Penguinsight.

To make sure my SvelteKit backend responds to CORS requests correctly, I wrote a SvelteKit server hook to add them. SvelteKit hooks can be used to run code on all incoming requests before handing them off to the SvelteKit router.

import type { type Handle = (input: {
    event: RequestEvent;
    resolve(event: RequestEvent, opts?: ResolveOptions): MaybePromise<Response>;
}) => MaybePromise<Response>
The [`handle`](https://kit.svelte.dev/docs/hooks#server-hooks-handle) hook runs every time the SvelteKit server receives a [request](https://kit.svelte.dev/docs/web-standards#fetch-apis-request) and determines the [response](https://kit.svelte.dev/docs/web-standards#fetch-apis-response). It receives an `event` object representing the request and a function called `resolve`, which renders the route and generates a `Response`. This allows you to modify response headers or bodies, or bypass SvelteKit entirely (for implementing routes programmatically, for example).
Handle
} from "@sveltejs/kit";
export const const allowCORS: ({ event, resolve }: { event: RequestEvent<Partial<Record<string, string>>, string | null>; resolve(event: RequestEvent<Partial<Record<string, string>>, string | null>, opts?: ResolveOptions | undefined): MaybePromise<...>; }) => Promise<...>allowCORS = (async ({ event: RequestEvent<Partial<Record<string, string>>, string | null>event, resolve: (event: RequestEvent<Partial<Record<string, string>>, string | null>, opts?: ResolveOptions | undefined) => MaybePromise<Response>resolve }) => { // 1. If the request isn't for a public API, don't add CORS headers const const url: URLurl = new var URL: new (url: string | URL, base?: string | URL | undefined) => URL
The URL interface represents an object providing static methods used for creating object URLs. [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL) `URL` class is a global reference for `require('url').URL` https://nodejs.org/api/url.html#the-whatwg-url-api
@sincev10.0.0
URL
(event: RequestEvent<Partial<Record<string, string>>, string | null>event.RequestEvent<Partial<Record<string, string>>, string | null>.request: Request
The original request object
request
.Request.url: string
Returns the URL of request as a string. [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/url)
url
);
if (!const url: URLurl.URL.pathname: string
[MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/pathname)
pathname
.String.startsWith(searchString: string, position?: number | undefined): boolean
Returns true if the sequence of elements of searchString converted to a String is the same as the corresponding elements of this object (converted to a String) starting at position. Otherwise returns false.
startsWith
('/api'))
return await resolve: (event: RequestEvent<Partial<Record<string, string>>, string | null>, opts?: ResolveOptions | undefined) => MaybePromise<Response>resolve(event: RequestEvent<Partial<Record<string, string>>, string | null>event); // 2. for options requests, just return the cors headers immediately. // Calling `resolve(event)` without an OPTIONS handler will throw an error. // If that error isn't handled, the CORS headers won't be added. if (event: RequestEvent<Partial<Record<string, string>>, string | null>event.RequestEvent<Partial<Record<string, string>>, string | null>.request: Request
The original request object
request
.Request.method: string
Returns request's HTTP method, which is "GET" by default. [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/method)
method
=== 'OPTIONS') {
return new var Response: new (body?: BodyInit | null | undefined, init?: ResponseInit | undefined) => Response
This Fetch API interface represents the response to a request. [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response)
Response
(null, { ResponseInit.headers?: HeadersInit | undefinedheaders: const corsHeaders: { 'Access-Control-Allow-Origin': string; 'Access-Control-Allow-Methods': string; }corsHeaders });
} // 3. For other request methods, let the sveltekit router resolve it, then add the CORS headers const const response: Responseresponse = await resolve: (event: RequestEvent<Partial<Record<string, string>>, string | null>, opts?: ResolveOptions | undefined) => MaybePromise<Response>resolve(event: RequestEvent<Partial<Record<string, string>>, string | null>event); for (const [const key: stringkey, const value: stringvalue] of var Object: ObjectConstructor
Provides functionality common to all JavaScript objects.
Object
.ObjectConstructor.entries<string>(o: { [s: string]: string; } | ArrayLike<string>): [string, string][] (+1 overload)
Returns an array of key/values of the enumerable properties of an object
@paramo Object that contains the properties and methods. This can be an object that you created or an existing Document Object Model (DOM) object.
entries
(const corsHeaders: { 'Access-Control-Allow-Origin': string; 'Access-Control-Allow-Methods': string; }corsHeaders)) {
const response: Responseresponse.Response.headers: Headers
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/headers)
headers
.Headers.set(name: string, value: string): void
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/set)
set
(const key: stringkey, const value: stringvalue);
} return const response: Responseresponse; }) satisfies type Handle = (input: { event: RequestEvent; resolve(event: RequestEvent, opts?: ResolveOptions): MaybePromise<Response>; }) => MaybePromise<Response>
The [`handle`](https://kit.svelte.dev/docs/hooks#server-hooks-handle) hook runs every time the SvelteKit server receives a [request](https://kit.svelte.dev/docs/web-standards#fetch-apis-request) and determines the [response](https://kit.svelte.dev/docs/web-standards#fetch-apis-response). It receives an `event` object representing the request and a function called `resolve`, which renders the route and generates a `Response`. This allows you to modify response headers or bodies, or bypass SvelteKit entirely (for implementing routes programmatically, for example).
Handle
;

This hook has 3 pieces:

  1. It checks if the requested path is under /api, the only Penguinsight resource I want to allow CORS access for. If not, it does nothing.
  2. If the request is a preflight OPTIONS request, it immediately returns success response with the headers specifying which types of requests allow CORS. I intentionally avoid any further sveltekit handling by returning without calling resolve(event)
  3. For all other requests, I let SvelteKit route the request to a handler with resolve(event), then add the CORS headers to the response that resulted from it before sending it back to the client.

I’ve run into CORS issues a few times and it always takes me hours to figure out what’s wrong. None of the resources I’ve found did a good job explaining why CORS exists, how it works, or the relevant nuances of the framework in question. I’ve tried to provide just enough detail on all of those that it makes sense. After finally narrowing it down to the smallest working solution, I wrote this note to document it for my future self and anyone else who needs to support CORS requests.

Troubleshooting other CORS issues

Invalid preflight OPTIONS response - 308 Redirect

I ran into this issue with both API calls and CORS requests for static assets. It was particularly difficult to debug because it only occurred when making requests against my production deployment; development and staging environments worked fine with no issues.

After an hour of detective work, I realized that http://localhost:5173/selection.js and https://some-deployment-string.vercel.app/selection.js both worked and https://penguinsight.com/selection.js because of how I had configured my domains in Vercel (my hosting provider). I have https://penguinsight.com configured to redirect to https://www.penguinsight.com (with a www.).

I had two options to fix this:

  1. Use www.penguinsight.com URIs in all my CORS requests
  2. Set https://penguinsight.com as the production domain instead

I opted for option 1 as it provides some extra flexibility for certain technical decisions in the future.

Addendum

If you need to support authenticated requests, requests with specific Content-Type, or other header information, there are some additional CORS headers you can add to allow them. Here’s an example of a more permissive set of CORS headers:

export const corsHeaders = {
  'Access-Control-Allow-Credentials': 'true',
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Methods': 'OPTIONS,POST,GET,PUT,PATCH,DELETE',
  'Access-Control-Allow-Headers':
    'authorization, x-client-info, apikey, X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version'
};

If any of those are not needed for your use case, I suggest not allowing them. Essentially these headers should be shrunk to the minimal set that works for your use case.