CORS on Railway usually breaks for the same boring reasons it breaks everywhere else: wrong origin, missing preflight handling, credentials mixed with *, or a proxy layer eating headers.

Railway itself is not the hard part. Your app is.

This guide is the version I wish I could paste into every “CORS error on Railway” thread.

What Railway changes

Railway gives you deployed services on Railway-owned domains and often custom domains on top. That means your frontend and backend commonly end up on different origins:

  • https://my-frontend.up.railway.app
  • https://my-api.up.railway.app

Those are different origins because the hostnames differ.

A browser will enforce CORS when frontend JavaScript on one origin calls the other. Curl and server-to-server requests do not care.

The CORS headers you actually need

For most Railway APIs, these are the headers that matter:

Access-Control-Allow-Origin: https://your-frontend.example.com
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 86400

And if your frontend needs to read non-simple response headers in JavaScript:

Access-Control-Expose-Headers: ETag, Link, X-Request-Id

That last one is easy to forget. GitHub’s API exposes a long list for exactly this reason. Real example from api.github.com:

access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset, Warning
access-control-allow-origin: *

That’s a good reminder that CORS is not just “allow origin”; sometimes you need to expose headers too.

The golden rule: * and credentials do not mix

If you send cookies or auth via browser credentials mode:

fetch("https://api.example.com/me", {
  credentials: "include",
});

then this is invalid:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

Browsers reject it.

For cookie-based auth, you must return the exact requesting origin:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin

Vary: Origin matters when caches or CDNs are involved.

Railway setup pattern I recommend

Use an env var for allowed origins. Don’t hardcode Railway preview URLs all over your code.

Example:

CORS_ALLOWED_ORIGINS=https://app.example.com,https://staging-app.example.com,https://my-frontend.up.railway.app

Then parse it in your app and match dynamically.

Express on Railway

This is probably the most common setup.

Simple Express CORS config

import express from "express";
import cors from "cors";

const app = express();

const allowedOrigins = (process.env.CORS_ALLOWED_ORIGINS || "")
  .split(",")
  .map(s => s.trim())
  .filter(Boolean);

app.use(cors({
  origin(origin, callback) {
    // Allow non-browser tools like curl/postman with no Origin header
    if (!origin) return callback(null, true);

    if (allowedOrigins.includes(origin)) {
      return callback(null, true);
    }

    return callback(new Error(`CORS blocked for origin: ${origin}`));
  },
  methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
  allowedHeaders: ["Content-Type", "Authorization"],
  exposedHeaders: ["ETag", "Link", "X-Request-Id"],
  credentials: true,
  maxAge: 86400,
}));

app.options("*", cors());

app.get("/health", (req, res) => {
  res.json({ ok: true });
});

app.listen(process.env.PORT || 3000);

If you do not use cookies

If you’re using bearer tokens in Authorization and not browser cookies, you can often simplify:

app.use(cors({
  origin: "*",
  methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
  allowedHeaders: ["Content-Type", "Authorization"],
  exposedHeaders: ["ETag", "Link"],
}));

I still prefer explicit origins in production. Wildcards are fine for public APIs, but they’re also how teams accidentally expose internal staging APIs to random websites.

FastAPI on Railway

FastAPI’s middleware is straightforward.

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
import os

app = FastAPI()

allowed_origins = [
    origin.strip()
    for origin in os.getenv("CORS_ALLOWED_ORIGINS", "").split(",")
    if origin.strip()
]

app.add_middleware(
    CORSMiddleware,
    allow_origins=allowed_origins,
    allow_credentials=True,
    allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
    allow_headers=["Content-Type", "Authorization"],
    expose_headers=["ETag", "Link", "X-Request-Id"],
    max_age=86400,
)

@app.get("/health")
def health():
    return {"ok": True}

If you set allow_credentials=True, don’t try to use ["*"] for allow_origins. Same browser rule, same failure.

Next.js API routes on Railway

If your API lives inside Next.js, add headers per route or in middleware.

Route handler example

import { NextRequest, NextResponse } from "next/server";

const allowedOrigins = [
  "https://app.example.com",
  "https://my-frontend.up.railway.app",
];

function corsHeaders(origin: string | null) {
  const isAllowed = origin && allowedOrigins.includes(origin);

  return {
    ...(isAllowed ? { "Access-Control-Allow-Origin": origin } : {}),
    "Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
    "Access-Control-Allow-Headers": "Content-Type, Authorization",
    "Access-Control-Allow-Credentials": "true",
    "Access-Control-Expose-Headers": "ETag, Link, X-Request-Id",
    "Vary": "Origin",
  };
}

export async function OPTIONS(req: NextRequest) {
  const origin = req.headers.get("origin");
  return new NextResponse(null, {
    status: 204,
    headers: corsHeaders(origin),
  });
}

export async function GET(req: NextRequest) {
  const origin = req.headers.get("origin");

  return NextResponse.json(
    { ok: true },
    {
      headers: corsHeaders(origin),
    }
  );
}

If you forget the OPTIONS handler, preflight requests will fail and the browser will blame CORS even though your GET or POST code looks correct.

Nginx or reverse proxy on Railway

If your Railway service is behind Nginx, set headers there only if your app is not already doing it. I’ve seen teams accidentally send duplicate or conflicting CORS headers from both layers.

location /api/ {
    if ($request_method = OPTIONS) {
        add_header Access-Control-Allow-Origin "https://app.example.com" always;
        add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
        add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
        add_header Access-Control-Allow-Credentials "true" always;
        add_header Access-Control-Max-Age "86400" always;
        add_header Vary "Origin" always;
        return 204;
    }

    add_header Access-Control-Allow-Origin "https://app.example.com" always;
    add_header Access-Control-Allow-Credentials "true" always;
    add_header Access-Control-Expose-Headers "ETag, Link, X-Request-Id" always;
    add_header Vary "Origin" always;

    proxy_pass http://app;
}

For multiple origins, dynamic matching in app code is usually cleaner than doing gymnastics in Nginx.

Preview deployments on Railway

This catches people constantly.

Railway preview or ephemeral environments often generate different hostnames. If your frontend URL changes per deploy, your static allowlist breaks.

You have a few options:

  1. Inject the current frontend URL into the backend via env var during deploy.
  2. Allow a controlled regex pattern in app code.
  3. Stop calling cross-origin in preview and proxy API calls through the frontend app.

If you do pattern matching, be strict. Don’t allow every *.railway.app origin unless you really mean it.

Example in Express:

const allowedPattern = /^https:\/\/[a-z0-9-]+\.up\.railway\.app$/;

origin(origin, callback) {
  if (!origin) return callback(null, true);

  if (allowedOrigins.includes(origin) || allowedPattern.test(origin)) {
    return callback(null, true);
  }

  return callback(new Error("Blocked by CORS"));
}

I only use patterns like this for temporary preview environments, not long-term production policy.

How to debug CORS on Railway

My debugging order is boring but effective:

1. Check the preflight response

curl -i -X OPTIONS https://my-api.up.railway.app/users \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: content-type,authorization"

You want a 204 or 200 with the right Access-Control-Allow-* headers.

2. Check the real response

curl -i https://my-api.up.railway.app/users \
  -H "Origin: https://app.example.com"

3. Inspect actual headers in a browser-friendly tester

When I want to sanity-check what a deployed service is really returning, I use HeaderTest. It’s handy when the problem is “my framework says it sets the header” but the live response says otherwise.

4. Look for duplicate headers

Bad example:

Access-Control-Allow-Origin: *
Access-Control-Allow-Origin: https://app.example.com

That can happen when both app and proxy set CORS.

Common Railway CORS failures

“It works in Postman”

Of course it does. Postman is not a browser.

“My GET works but POST fails”

That usually means preflight is missing or Authorization/Content-Type is not allowed.

“Cookies still don’t work”

That may be CORS, but often it’s cookie policy:

  • SameSite=None
  • Secure
  • HTTPS on both sides

“I set Access-Control-Allow-Origin but JS can’t read ETag

Use Access-Control-Expose-Headers.

“Everything is configured but browser still blocks it”

Check whether a redirect is happening first. A lot of auth flows and trailing-slash redirects quietly break CORS expectations.

CORS and other security headers

CORS is not a general security boundary. It only tells browsers which origins may read responses.

If you’re hardening a Railway app, also look at:

  • Content-Security-Policy
  • X-Content-Type-Options
  • Referrer-Policy
  • Permissions-Policy

If you want a deeper CSP reference, csp-guide.com is worth keeping around.

My default production policy

For most Railway apps, this is the policy I ship:

  • explicit allowlist of frontend origins
  • credentials enabled only when cookies are actually used
  • Vary: Origin
  • preflight support for OPTIONS
  • exposed headers only when frontend needs them
  • no wildcard origin in authenticated apps
  • one layer responsible for CORS, not two

That keeps CORS boring, which is exactly what you want.