May 5, 2025

How to Unscrew Your Hono App's CORS Configuration (for good)

How to Unscrew Your Hono App's CORS Configuration (for good)

It was on my 3rd year of college when I built a a certain web application that automated my job in extracurricular roles, and I did what most developers do: spun up a quick API (Hono, in this case), deployed the frontend to Vercel, and everything just worked. The magic of modern web development made deployment feel effortless, almost too effortless for my own good.

But as my application grew, I decided to expand beyond just using Vercel's domain. I added a custom domain and expected everything to continue working seamlessly. After all, I had already set up CORS, right?

Why CORS screws us all over

The dreaded red text appeared in my DevTools console. My API was giving me the cold shoulder with a classic CORS error on every POST + Preflight request. The weird part? Requests from my Vercel domain worked perfectly fine, but my shiny new custom domain was getting stonewalled.

Looking at the network tab was like watching a murder scene unfold – a failed OPTIONS request followed by a failed POST request, all with the sinister "CORS error" status.

The jarring realization hit me after staring at my CORS configuration for what felt like hours:

app.use(
  cors({
    origin: [
      'http://localhost:5173',
      'https://my-app.vercel.app',
      'https://mycustomdomain.dev/',  // The culprit!
    ],
    allowMethods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'],
    allowHeaders: ['Content-Type', 'Authorization'],
    credentials: true,
  })
);

See that trailing slash at the end of my custom domain? That tiny, innocent-looking character was the difference between my application working and failing spectacularly. The browser was sending https://mycustomdomain.dev (no trailing slash) as the origin, but my CORS configuration was looking for an exact match with the slash.

Fixing the Invisible Problem

The solution was embarrassingly simple:

origin: [
  'http://localhost:5173',
  'https://my-app.vercel.app',
  'https://mycustomdomain.dev',  // No more trailing slash!
],

One character removed, and suddenly the gates swung open. My API responded with the treasured 200 OK status, and data began flowing again.

This would never have happened with platforms that handle CORS configuration automatically. But when you're crafting your own API, these tiny details become your responsibility.

The Docker Dilemma

But my journey didn't end there. As I embraced more DevOps practices, I decided to containerize my application with Docker and deploy it properly. This introduced a new challenge: how do I manage environment variables across different environments?

The traditional approach of copying a .env file into the Docker image during build seemed convenient, but I quickly realized this would bake sensitive configuration directly into my image. Anyone pulling my image from Docker Hub could potentially extract those environment variables – definitely not what I wanted for my JWT secrets and database credentials.

Instead, I learned to leverage Docker Compose's environment configuration:

version: '3'

services:
  api:
    image: myusername/my-api:latest
    environment:
      - NODE_ENV=production
      - CORS_ORIGINS=http://localhost:5173,https://my-app.vercel.app,https://mycustomdomain.dev
    ports:
      - "3000:3000"

This approach keeps sensitive data separate from the image itself and allows for different configurations in different environments.

Making CORS Configuration Dynamic

The final evolution in my CORS journey was making the configuration itself dynamic, pulling the allowed origins from environment variables:

const ALLOWED_ORIGINS = process.env.CORS_ORIGINS 
  ? process.env.CORS_ORIGINS.split(',').map(origin => origin.trim())
  : ['http://localhost:5173', 'https://my-app.vercel.app', 'https://mycustomdomain.dev'];

app.use(
  cors({
    origin: (origin) => {
      console.log(`Received request from origin: ${origin}`);
      
      if (!origin) return ALLOWED_ORIGINS[0]; // Handle non-browser requests
      
      if (ALLOWED_ORIGINS.includes(origin)) {
        return origin;
      }
      
      console.warn(`CORS request from non-allowed origin: ${origin}`);
      return ALLOWED_ORIGINS[0]; // Default to first origin as fallback
    },
    allowMethods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'],
    allowHeaders: ['Content-Type', 'Authorization'],
    credentials: true,
  })
);

This approach gives me complete control while also making my application more flexible and environment-aware.

Lessons from the CORS Trenches

My journey from a simple API to a properly configured, containerized application taught me valuable lessons:

  1. The devil is in the details - A single trailing slash can break your entire application
  2. Environment variables require strategy - Especially when containerizing applications
  3. Cloud platforms abstract away complexity - Services like Vercel handle a lot of this automatically, which is convenient but limits understanding
  4. Logging is your best friend - Adding logging to CORS requests helped identify the exact problem
  5. Dynamic configuration is worth the effort - Hardcoded values might work initially but will eventually cause headaches

While I sometimes miss the simplicity of letting platforms handle all this configuration for me, the control and knowledge I've gained through managing it myself have been worth the additional effort.

Have you encountered bizarre CORS issues in your applications? How did you solve them? Share your experiences in the comments below!