Configure CORS for your Extend app
Overview
Enable Cross-Origin Resource Sharing (CORS) when a browser-based web application on a different domain (for example https://mygame.com) needs to call your Extend REST endpoints.
The Extend sample apps ship with CORS support built in but disabled by default. You turn it on and configure it entirely through environment variables — no code changes and no rebuild. While CORS is disabled, your app behaves exactly as before and adds no CORS headers.
This applies to Extend apps that expose REST endpoints through the gRPC gateway: Extend Service Extension and Extend Event Handler with REST API support.
CORS is enforced by the browser, not your app. It only affects JavaScript requests from a web page on a different origin (scheme + host + port). Server-to-server and non-browser clients are unaffected.
This page covers CORS for your own Extend app. It is a different setting from the namespace-level Allowed Origins (CORS) configuration in the AGS Admin Portal under Foundations > Tools and Utilities, which controls cross-origin access to AGS platform endpoints. Configuring one has no effect on the other.
Before you begin
-
A deployed Extend Service Extension, or an Extend Event Handler with REST API support.
-
Your app includes the built-in CORS middleware. To check, look for one of these files in your app's source:
pkg/common/cors.go— Go sample apps.gateway/pkg/common/cors.go— C#, Java, and Python sample apps, and Event Handler with REST API support.
If neither file exists, your app predates the built-in middleware. Follow If your app does not include the CORS middleware instead.
-
Access to your app's Environment Variable Configuration in the AGS Admin Portal.
-
The exact origins that will call your app.
https://mygame.comandhttps://admin.mygame.comare different origins.
Step 1: Set the CORS environment variables
Add the following as Environment Variable Configuration entries on your Extend app. For the Admin Portal steps, see Extend app environment variables and secrets.
| Key | Value |
|---|---|
CORS_ENABLED | true |
CORS_ALLOWED_ORIGINS | https://mygame.com |
That is enough for typical GET and POST requests. The app applies sensible defaults for every other setting — see Settings reference.
To allow more than one origin, use a comma-separated list of exact origins:
| Key | Value |
|---|---|
CORS_ALLOWED_ORIGINS | https://mygame.com,https://admin.mygame.com |
To let the browser send cookies or the Authorization header on cross-origin requests, also add CORS_ALLOW_CREDENTIALS with the value true.
Do not combine CORS_ALLOW_CREDENTIALS=true with CORS_ALLOWED_ORIGINS=*. This exact combination is invalid per the CORS specification, so browsers reject it and the request simply fails. More importantly, the intent behind it is dangerous: allowing credentialed requests from any origin lets any website a signed-in user visits call your app as that user and read the response, exposing session cookies and access tokens (a cross-site request forgery, or CSRF, risk). Whenever CORS_ALLOW_CREDENTIALS=true, restrict CORS_ALLOWED_ORIGINS to an explicit list of exact origins you trust.
Step 2: Restart and apply
Click Restart and Apply on your app's configuration page. The CORS variables are read only at startup, so the new values take effect when the app restarts.
Settings reference
Each setting is read from the matching environment variable at startup.
| Environment variable | Default | Description |
|---|---|---|
CORS_ENABLED | false | Master switch. When false, no CORS headers are added and every other CORS variable is ignored. |
CORS_ALLOWED_ORIGINS | (empty) | Comma-separated exact origins, for example https://mygame.com. Set * to allow any origin (not recommended). If this is empty while CORS_ENABLED=true, the app leaves CORS disabled and logs an error — see Troubleshooting. |
CORS_ALLOWED_METHODS | GET,POST,PUT,DELETE,PATCH,OPTIONS | Permitted HTTP methods. |
CORS_ALLOWED_HEADERS | Content-Type,Authorization | Request headers the browser may send. Add any custom headers your app reads. |
CORS_EXPOSE_HEADERS | (empty) | Response headers browser JavaScript may read. |
CORS_ALLOW_CREDENTIALS | false | Allow cookies and Authorization on cross-origin requests. Requires exact origins. |
CORS_MAX_AGE | 600 | Seconds the browser may cache the preflight response. Browsers cap this regardless (roughly two hours in Chromium). |
Setting a variable to an empty value overrides its default rather than falling back to it. For example, adding CORS_ALLOWED_METHODS with an empty value allows no methods at all, instead of the six defaults. To keep a default, leave the variable out of your configuration entirely.
CORS applies to your app's REST and HTTP surface only — the gRPC gateway, including its Swagger UI. The metrics endpoint and the raw gRPC port are unaffected.
Prefer exact origins over *, never enable credentials with a wildcard or unrestricted origin (see the warning above), and trim methods and headers to what your app uses. CORS configuration is your responsibility as the app owner.
Test your configuration
Check the startup logs first. When CORS is active, your app logs a CORS enabled line at startup listing the allowed origins and whether credentials are allowed. This line only appears when CORS is genuinely adding headers, so its absence tells you the configuration did not take effect — check that before debugging the browser.
Against the deployed app. From a page served on an allowed origin, call the https:// URL of your app, then check the browser's Network tab for the OPTIONS preflight and its Access-Control-Allow-Origin response header:
fetch("https://<your-extend-app-url>/v1/your-endpoint", {
method: "GET",
headers: { "Authorization": "Bearer <token>" },
})
.then((res) => res.json())
.then(console.log);
Locally. Run the app locally and send a preflight with curl — calling localhost directly avoids the ingress that can intercept OPTIONS on the deployed URL:
curl -i -X OPTIONS "http://localhost:8000/v1/your-endpoint" \
-H "Origin: https://mygame.com" \
-H "Access-Control-Request-Method: GET"
A correct response includes Access-Control-Allow-Origin: https://mygame.com. Repeat with a disallowed origin (for example https://evil.com) and confirm that header is absent — that proves your allowlist is enforced.
Troubleshooting
- No CORS headers, and the logs show an error about
CORS_ALLOWED_ORIGINSbeing empty: you setCORS_ENABLED=truebut listed no origins. The app deliberately leaves CORS disabled in that case, because an empty origin list would otherwise allow every origin. Add the origins you want to allow, or set*to allow all intentionally. - No CORS headers and no CORS log lines at all: your app does not include the built-in middleware. See If your app does not include the CORS middleware.
- CORS error despite correct config: call the
https://URL, nothttp://. Extend301-redirectshttp://tohttps://, and browsers do not follow redirects during a preflightOPTIONS. - Preflight
OPTIONSfails: confirmCORS_ENABLED=true, the origin is inCORS_ALLOWED_ORIGINS, and the method and headers are allowed. curlshows different headers than the browser: the Extend ingress may interceptOPTIONS. Verify with a real browserfetchfrom an allowed origin.
If your app does not include the CORS middleware
Follow this section only when the file check in Before you begin found no cors.go — for example an app scaffolded from a sample app before the middleware was added, or an app written from scratch. Adding the middleware yourself gives you the same CORS_* environment variables described above.
Unlike the environment-variable steps, this path changes your app's source code, so you must rebuild and redeploy your app afterward. Redeploying the existing build is not enough.
Your Extend app may be written in C#, Java, or Python, but its HTTP entry point — the gRPC gateway — is always written in Go. CORS is an HTTP concern, so you add it in Go, regardless of your app's language. You don't need to write Go — paste the file below as-is.
The steps below use the C#, Java, and Python layout, where the gateway is a separate component in the gateway/ folder. If your app is written in Go, the gateway is the app itself — follow the paths in the right-hand column and use slog in place of logrus:
| C# / Java / Python | Go | |
|---|---|---|
| CORS file (Step 1) | gateway/pkg/common/cors.go | pkg/common/cors.go |
| Wiring file (Step 3) | gateway/main.go | main.go (repo root) |
Run go get in (Step 2) | gateway/ | repo root |
| Logger | *logrus.Logger | *slog.Logger |
For the Go app, replace the github.com/sirupsen/logrus import in cors.go with log/slog and change the parameter to logger *slog.Logger. The logger.Error(...) and logger.Warn(...) calls work the same with both loggers.
Step 1: Add the CORS middleware file
Create a new file at gateway/pkg/common/cors.go and paste the following:
// gateway/pkg/common/cors.go
package common
import (
"net/http"
"strings"
"github.com/rs/cors"
"github.com/sirupsen/logrus"
)
// CORSConfig holds the cross-origin resource sharing settings for the
// gRPC-Gateway HTTP entry point. Each field maps to a CORS_* environment
// variable read by LoadCORSConfigFromEnv. CORS is disabled unless Enabled
// is true, so existing deployments are unaffected until you opt in.
type CORSConfig struct {
Enabled bool
AllowedOrigins []string
AllowedMethods []string
AllowedHeaders []string
ExposeHeaders []string
AllowCredentials bool
MaxAge int
}
// Active reports whether CORS is both switched on and actually able to add
// headers, which requires at least one allowed origin.
func (c CORSConfig) Active() bool {
return c.Enabled && len(c.AllowedOrigins) > 0
}
// LoadCORSConfigFromEnv reads the CORS configuration from environment
// variables. CORS is off by default: unless CORS_ENABLED=true the returned
// config is inert and NewCORSMiddleware becomes a passthrough.
func LoadCORSConfigFromEnv() CORSConfig {
return CORSConfig{
Enabled: strings.EqualFold(GetEnv("CORS_ENABLED", "false"), "true"),
AllowedOrigins: splitAndTrim(GetEnv("CORS_ALLOWED_ORIGINS", "")),
AllowedMethods: splitAndTrim(GetEnv("CORS_ALLOWED_METHODS", "GET,POST,PUT,DELETE,PATCH,OPTIONS")),
AllowedHeaders: splitAndTrim(GetEnv("CORS_ALLOWED_HEADERS", "Content-Type,Authorization")),
ExposeHeaders: splitAndTrim(GetEnv("CORS_EXPOSE_HEADERS", "")),
AllowCredentials: strings.EqualFold(GetEnv("CORS_ALLOW_CREDENTIALS", "false"), "true"),
MaxAge: GetEnvInt("CORS_MAX_AGE", 600),
}
}
// NewCORSMiddleware returns an HTTP middleware that applies the given CORS
// config. When CORS is not usable it returns an identity middleware that adds
// no headers. When enabled it delegates to rs/cors, which answers preflight
// OPTIONS requests and sets the Vary header.
func NewCORSMiddleware(cfg CORSConfig, logger *logrus.Logger) func(http.Handler) http.Handler {
passthrough := func(next http.Handler) http.Handler { return next }
if !cfg.Enabled {
return passthrough
}
// rs/cors treats an empty AllowedOrigins list as "allow every origin".
// Arriving there by omission is a misconfiguration rather than an intent,
// so refuse to enable CORS instead of silently exposing the API to any
// origin. Allowing everything stays possible, but only explicitly via
// CORS_ALLOWED_ORIGINS=*.
if len(cfg.AllowedOrigins) == 0 {
logger.Error("CORS_ENABLED=true but CORS_ALLOWED_ORIGINS is empty; leaving CORS disabled because an empty origin list would allow every origin. Set explicit origins (e.g. https://mygame.com), or '*' to allow all intentionally")
return passthrough
}
if cfg.AllowCredentials && containsWildcard(cfg.AllowedOrigins) {
logger.Warn("insecure CORS configuration: CORS_ALLOW_CREDENTIALS=true combined with a wildcard origin '*' is invalid per the CORS spec and will be rejected by browsers; specify exact origins (e.g. https://mygame.com) instead")
}
c := cors.New(cors.Options{
AllowedOrigins: cfg.AllowedOrigins,
AllowedMethods: cfg.AllowedMethods,
AllowedHeaders: cfg.AllowedHeaders,
ExposedHeaders: cfg.ExposeHeaders,
AllowCredentials: cfg.AllowCredentials,
MaxAge: cfg.MaxAge,
})
return c.Handler
}
// splitAndTrim splits a comma-separated list, trims whitespace from each
// item, and drops empty entries.
func splitAndTrim(s string) []string {
if strings.TrimSpace(s) == "" {
return []string{}
}
parts := strings.Split(s, ",")
result := make([]string, 0, len(parts))
for _, p := range parts {
if trimmed := strings.TrimSpace(p); trimmed != "" {
result = append(result, trimmed)
}
}
return result
}
// containsWildcard reports whether the origins list contains "*".
func containsWildcard(origins []string) bool {
for _, o := range origins {
if o == "*" {
return true
}
}
return false
}
cors.go calls the GetEnv and GetEnvInt helpers from the same package. Most sample apps already define them in pkg/common/utils.go. If your gateway's pkg/common folder does not have them, add them before continuing, or cors.go will not compile.
Step 2: Add the CORS dependency
The file uses the rs/cors package. From inside the gateway/ folder, run:
go get github.com/rs/cors@v1.11.1
This adds the dependency to your go.mod and go.sum. Commit both files.
Step 3: Wire it into the gateway
In gateway/main.go, find the newGRPCGatewayHTTPServer function. It builds a loggedMux and serves it as the Handler. Wrap loggedMux with the CORS middleware and serve that instead, so CORS is the outermost layer and browser preflight (OPTIONS) requests are answered before reaching the gateway:
loggedMux := loggingMiddleware(logger, mux)
// Add CORS middleware as the outermost layer so that browser preflight
// (OPTIONS) requests are answered here before reaching the gateway mux.
// It is inert unless CORS_ENABLED=true, so behavior is unchanged by default.
corsConfig := common.LoadCORSConfigFromEnv()
corsHandler := common.NewCORSMiddleware(corsConfig, logger)(loggedMux)
if corsConfig.Active() {
logger.WithFields(logrus.Fields{
"allowedOrigins": corsConfig.AllowedOrigins,
"allowCredentials": corsConfig.AllowCredentials,
}).Info("CORS enabled")
}
return &http.Server{
Addr: addr,
Handler: corsHandler, // was: loggedMux
// ...
}
NewCORSMiddleware and LoadCORSConfigFromEnv are in the common package — the package behind the common. prefix already used throughout main.go — so no new import is needed. If your editor reports a missing package, run go mod tidy inside gateway/.
Step 4: Build, deploy, and enable CORS
- Verify the change compiles — run
go build ./...insidegateway/(or the repository root for the Go app). A compile error here usually points to a typo in the pastedcors.go. - Build and deploy your updated app using its normal Extend build-and-deploy process.
- Set the CORS environment variables as described in Step 1: Set the CORS environment variables, then Restart and Apply.