CORS
This guide covers Cross-Origin Resource Sharing (CORS) in Tinka. You will learn how to:
- Let a front end on another origin call your App
- Send cookies and
Authorizationfrom that front end - Allow every subdomain, and every port on
localhost - Choose the methods and headers other origins may use
- Let pages read your response headers
- Serve a public API that any site can read
- Understand how the App answers preflights, and what it refuses at startup
Overview
A browser keeps a page from reading responses that come from another origin: another scheme, host or port. A front end at https://app.example.com that calls an API at https://api.example.com is cross-origin, and so is a dev server at http://localhost:5173 that calls an App at http://localhost:3000. Without that rule, any site a user visits could read their data from every other site they're signed in to.
CORS is how a server tells the browser which other origins may read its responses. Before a request that a plain HTML form couldn't send, such as a DELETE or a JSON body, the browser first asks with an OPTIONS request, the preflight, and sends the real request only if the answer allows it.
A Tinka App allows no other origin until you list one in HTTPConfig.CORS. The App answers preflights itself, before any middleware runs, so authentication middleware never refuses them. It adds the CORS headers to every response, errors included, so a page can read why a request failed.
Allow an origin
List the origins of your front ends in Origins, written as the browser sends them: the scheme, the host in lower case, and a port only when it isn't the default one:
package config
import (
"strconv"
"github.com/mucolabs/tinka"
"github.com/mucolabs/tinka/env"
)
func httpConfig(e *env.Env) tinka.HTTPConfig {
return tinka.HTTPConfig{
// Addr is where the App listens. act serve listens for it in
// development.
Addr: ":" + strconv.Itoa(e.Int("PORT", 3000)),
// Debug shows unexpected errors' messages and stacks in responses.
// It's off unless APP_DEBUG=true, which .env sets on your machine:
// production reads no .env file, so it stays off there even when
// APP_ENV is forgotten.
Debug: e.Bool("APP_DEBUG", false),
CORS: tinka.CORSConfig{
Origins: []string{"https://app.example.com"},
},
}
}Pages from https://app.example.com can now call the App with the methods GET, HEAD, POST, PUT, PATCH and DELETE, send any header a page may set, such as Content-Type: application/json and Authorization, and read the responses. Other origins still can't.
A listed origin may also send requests that change data. The App refuses POST, PUT, PATCH and DELETE from another origin's pages, to stop cross-site request forgery, and trusts the origins you list here.
Read the origins from the environment
Front ends often live at different addresses in staging and production. Read them from a variable that lists origins separated by spaces, with strings.Fields (add "strings" to the file's imports):
CORS: tinka.CORSConfig{
Origins: strings.Fields(e.String("CORS_ORIGINS", "")),
},CORS_ORIGINS=http://localhost:5173An empty or missing variable lists no origins, which turns CORS off.
Send cookies
A page's request carries no cookies to another origin, and its page can't read a response that set any, unless the server allows credentials. Turn Credentials on for a front end that signs in with a session cookie:
CORS: tinka.CORSConfig{
Origins: []string{"https://app.example.com"},
Credentials: true,
},The front end must ask for them too, with credentials: "include" in fetch. A front end that sends a token in Authorization doesn't need credentials: the header is allowed without them.
With credentials, every origin must use HTTPS, except on localhost. Over plain HTTP, anyone on the network between the user and that origin could serve their own page as it, and act with the user's cookies. The App refuses to start with such an origin.
Allow subdomains and local ports
Two patterns cover origins you can't list one by one:
CORS: tinka.CORSConfig{
Origins: []string{"https://app.example.com", "https://*.preview.example.com", "http://localhost:*"},
},Choose methods and headers
Browsers always allow GET, HEAD and POST. Methods lists the other methods pages may use, and defaults to PUT, PATCH and DELETE. Method names are case-sensitive, so list them as your front end sends them. fetch turns delete and put into upper case, but sends patch as written:
CORS: tinka.CORSConfig{
Origins: []string{"https://app.example.com"},
Methods: []string{"PUT", "DELETE"},
},Headers lists the request headers pages may send. It defaults to any header a page may set; list them to allow only those. Case doesn't matter:
CORS: tinka.CORSConfig{
Origins: []string{"https://app.example.com"},
Headers: []string{"Content-Type", "Authorization"},
},A JSON body needs Content-Type: only form and plain text bodies go without asking.
Expose response headers
A page reads only a few response headers from another origin, such as Content-Type. ExposeHeaders lists the others it may read:
CORS: tinka.CORSConfig{
Origins: []string{"https://app.example.com"},
ExposeHeaders: []string{"X-Request-Id", "Location"},
},Cache preflights
A browser reuses the answer to a preflight for MaxAge, so later requests skip the extra round trip. It defaults to two hours, the longest Chromium honors. Firefox honors up to 24 hours, the most MaxAge accepts. A negative value makes browsers ask before every request, which helps while you change the configuration:
CORS: tinka.CORSConfig{
Origins: []string{"https://app.example.com"},
MaxAge: -1,
},Serve a public API
"*" lets every origin read the App's responses, for public data such as an open API or a status page. Every response says so with Access-Control-Allow-Origin: *:
CORS: tinka.CORSConfig{
Origins: []string{"*"},
},"*" stands alone, and can't be used with Credentials: browsers refuse that combination, because every site could then read what your users see.
How the App answers
A preflight is an OPTIONS request with Origin and Access-Control-Request-Method headers. The App answers it before any middleware runs:
- When the origin, the method and every header it asks about are allowed, with
204 No Contentand the headers that allow them. - Otherwise, with
403 Forbiddenand the codecors_refused, and no CORS headers, so the browser stops and tells the page nothing about the configuration. The browser's console says the request was blocked by CORS policy, and the App logs the refusal, with its reason, at the warning level.
Any other OPTIONS request reaches the router as usual.
Every other response carries Vary: Origin, because whether it allows the page to read it depends on the Origin header. Caches then keep one copy per origin, instead of handing one origin's answer to another. A handler that varies its response on another header adds to it with c.Header().Add("Vary", "Accept"); Set would remove Origin.
Mistakes the App refuses
The App checks the configuration when it's created, and panics on a mistake that would never work in a browser or would open the App to every site. The message names the origin or the value, and how to write it:
| Mistake | Why |
|---|---|
"*" with Credentials, or "*" with other origins |
Browsers refuse it; "*" already allows every origin |
"null" |
Browsers send null from sandboxed pages, which any site can create |
A path or a trailing /, such as https://app.example.com/ |
An origin is only a scheme, a host and a port |
Upper case, a default port such as :443, or a host not in Punycode |
Browsers never send them, so the origin would never match |
http:// with Credentials, other than on localhost |
Anyone on the network could act as the page |
A wildcard over a public suffix, such as https://*.github.io |
It matches other people's sites |
CONNECT or TRACE in Methods, Cookie or Sec-* in Headers, Set-Cookie in ExposeHeaders |
Browsers never send or expose them |
A MaxAge over 24 hours, or not in whole seconds |
Browsers cap it, and send whole seconds |
See also: Security headers, Middleware, App reference, Configuration