Skip to main content

Cross-Origin Resource Sharing (CORS) in .htaccess

Modern browsers enforce a strict boundary called the same-origin policy that prevents one website from reading data belonging to another. This is a fundamental security mechanism, but it also blocks many legitimate use cases: loading fonts from a CDN, calling an API on a different subdomain, or sharing resources between related applications. Cross-Origin Resource Sharing (CORS) is the standardized mechanism that lets servers selectively relax this boundary.

This guide explains the same-origin policy, what CORS enables, how to configure each CORS header in .htaccess, how to handle preflight requests, and provides specific patterns for common scenarios like serving web fonts across domains.

Same-Origin Policy

The same-origin policy is a browser security model that restricts how documents and scripts from one origin can interact with resources from another origin. Two URLs have the same origin only if they share the same scheme (protocol), host (domain), and port.

URL AURL BSame Origin?Reason
https://example.com/pagehttps://example.com/otherYesSame scheme, host, port
https://example.comhttp://example.comNoDifferent scheme
https://example.comhttps://api.example.comNoDifferent host
https://example.comhttps://example.com:8443NoDifferent port
https://example.comhttps://example.orgNoDifferent host

When a web page makes a request to a different origin (using fetch(), XMLHttpRequest, or by loading fonts, etc.), the browser checks whether the target server explicitly allows cross-origin access. Without CORS headers in the response, the browser blocks the response from reaching the requesting script.

This blocking happens at the browser level. The server still processes the request and sends the response. The browser receives it, checks for CORS headers, and if they are missing or do not match, refuses to expose the response to the JavaScript code.

note

The same-origin policy only applies to browser-initiated requests. Server-to-server requests, command-line tools like curl, and mobile app HTTP clients are not affected. CORS is purely a browser security mechanism.

What CORS Enables

CORS allows a server to declare: "I permit requests from these specific origins" by including special response headers. When the browser sees these headers, it allows the cross-origin response to be read by the requesting page.

Common scenarios where CORS is required:

ScenarioExample
API on a different subdomainapp.example.com calling api.example.com
Loading web fonts from a CDNYour site loading fonts from fonts.example-cdn.com
Shared image or media assetsMultiple sites loading images from assets.example.com
Single-page application with a separate backendReact app on app.com calling API on api.app.com
Third-party integrationsYour widget embedded on customer sites needing API access
Cross-origin resource timing and performance dataMonitoring tools measuring load times of third-party resources

Without CORS, all of these scenarios fail silently in the browser. The request appears to work (the server processes it), but the browser blocks the JavaScript code from accessing the response.

Access-Control-Allow-Origin

The Access-Control-Allow-Origin header is the core of CORS. It tells the browser which origins are allowed to read the response. Without this header, no cross-origin access is permitted.

Wildcard

The simplest CORS configuration allows access from any origin:

<IfModule mod_headers.c>
Header set Access-Control-Allow-Origin "*"
</IfModule>

The * wildcard means every website in the world can make cross-origin requests to your server and read the responses.

When a wildcard is appropriate:

  • Public APIs that serve open data (weather, public datasets, etc.).
  • Public CDNs serving static assets (fonts, libraries, images).
  • Resources that are intentionally available to everyone.

When a wildcard is dangerous:

  • APIs that return user-specific data.
  • Any endpoint that uses cookies or authentication.
  • Internal or private resources.
warning

The wildcard * cannot be combined with Access-Control-Allow-Credentials: true. If you need to send cookies or authentication headers with cross-origin requests, you must specify exact origins instead of using the wildcard. This is a deliberate security restriction in the CORS specification.

Specific Origins

To restrict access to one specific origin:

<IfModule mod_headers.c>
Header set Access-Control-Allow-Origin "https://app.example.com"
</IfModule>

Only pages served from https://app.example.com can read cross-origin responses from your server. Requests from any other origin are blocked by the browser.

The value must be a complete origin including the scheme:

Wrong approach:

# Missing scheme - this will not work correctly
Header set Access-Control-Allow-Origin "app.example.com"

Correct approach:

# Full origin with scheme
Header set Access-Control-Allow-Origin "https://app.example.com"

Dynamic Origin with Environment Variables

The Access-Control-Allow-Origin header only accepts a single origin or *. You cannot list multiple origins directly:

This does not work:

# INVALID - the header does not support multiple origins
Header set Access-Control-Allow-Origin "https://app1.example.com https://app2.example.com"

To allow multiple specific origins, you need to dynamically set the header based on the incoming request's Origin header. Apache's SetEnvIf directive makes this possible:

Dynamic CORS for multiple origins
<IfModule mod_setenvif.c>
<IfModule mod_headers.c>
# Check the Origin header against allowed domains
SetEnvIf Origin "^https://(app1\.example\.com|app2\.example\.com|staging\.example\.com)$" CORS_ORIGIN=$0

# If the Origin matched, set the CORS header to that specific origin
Header set Access-Control-Allow-Origin "%{CORS_ORIGIN}e" env=CORS_ORIGIN
Header set Vary "Origin" env=CORS_ORIGIN
</IfModule>
</IfModule>

How this works:

  1. SetEnvIf Origin checks the Origin request header against a regex pattern listing your allowed domains.
  2. If the Origin matches, the entire matched value is stored in the CORS_ORIGIN environment variable ($0 captures the full match).
  3. Header set ... env=CORS_ORIGIN only adds the header if the variable exists (meaning the origin was in the allowed list).
  4. Header set Vary "Origin" tells caches that the response varies based on the Origin header. This is critical. Without Vary, a CDN or proxy might cache a response with Access-Control-Allow-Origin: https://app1.example.com and serve it to a request from app2.example.com, which would fail.
caution

Always include Vary: Origin when you dynamically set Access-Control-Allow-Origin based on the request. Without it, intermediate caches (CDNs, reverse proxies) may serve a cached response with the wrong origin, causing intermittent CORS failures that are extremely difficult to debug.

Applying CORS to Specific File Types

Rather than applying CORS headers to your entire site, you can restrict them to specific resource types:

CORS for images only
<IfModule mod_setenvif.c>
<IfModule mod_headers.c>
<FilesMatch "\.(bmp|cur|gif|ico|jpe?g|a?png|svgz?|webp|heic|heif|avif)$">
SetEnvIf Origin ":" IS_CORS
Header set Access-Control-Allow-Origin "*" env=IS_CORS
</FilesMatch>
</IfModule>
</IfModule>

The SetEnvIf Origin ":" IS_CORS line is a clever trick: it checks whether the Origin header contains a colon, which every valid origin does (e.g., https://example.com). If an Origin header is present, the request is cross-origin, and the CORS header is added. Same-origin requests do not include an Origin header, so the CORS header is not sent unnecessarily.

Access-Control-Allow-Methods

The Access-Control-Allow-Methods header specifies which HTTP methods are permitted for cross-origin requests. This header is primarily relevant in preflight responses (explained in a later section).

<IfModule mod_headers.c>
Header set Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
</IfModule>

By default, CORS only allows simple methods: GET, HEAD, and POST. Any other method (like PUT, DELETE, or PATCH) triggers a preflight request, and the server must explicitly list the method in this header for it to be allowed.

MethodRequires Explicit Listing?Common Use
GETNo (simple method)Reading data
HEADNo (simple method)Checking resource metadata
POSTNo (simple method)*Submitting data
PUTYesUpdating/replacing a resource
DELETEYesDeleting a resource
PATCHYesPartially updating a resource
OPTIONSYes (for preflight)Preflight request

*POST with certain content types (like application/json) also triggers a preflight request.

Only list the methods your application actually uses:

# For a read-only API
Header set Access-Control-Allow-Methods "GET, OPTIONS"

# For a full CRUD API
Header set Access-Control-Allow-Methods "GET, POST, PUT, DELETE, PATCH, OPTIONS"

Access-Control-Allow-Headers

The Access-Control-Allow-Headers header specifies which request headers the client is allowed to send in cross-origin requests. Like Allow-Methods, this is primarily used in preflight responses.

<IfModule mod_headers.c>
Header set Access-Control-Allow-Headers "Content-Type, Authorization, X-Requested-With"
</IfModule>

Certain headers are always allowed without explicit listing (called "CORS-safelisted" headers):

  • Accept
  • Accept-Language
  • Content-Language
  • Content-Type (only for application/x-www-form-urlencoded, multipart/form-data, or text/plain)

Any other header triggers a preflight request and must be listed in Access-Control-Allow-Headers:

HeaderRequires Listing?Common Use
Content-Type*Yes (for JSON)Sending application/json requests
AuthorizationYesBearer tokens, API keys
X-Requested-WithYesAJAX detection (jQuery convention)
X-Custom-HeaderYesAny custom header
AcceptNoAccepted response types
Accept-LanguageNoLanguage preferences

*Content-Type is safelisted only for form-style types. When sending JSON (application/json), it must be explicitly allowed.

Common Mistake: Forgetting Content-Type for JSON APIs

Wrong approach:

# Does not list Content-Type
Header set Access-Control-Allow-Headers "Authorization"

A front-end application sending a JSON POST request:

fetch('https://api.example.com/data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'test' })
});

This fails because Content-Type: application/json is not a safelisted value, and the server does not include Content-Type in Allow-Headers. The preflight request is rejected.

Correct approach:

Header set Access-Control-Allow-Headers "Content-Type, Authorization"

Access-Control-Allow-Credentials

The Access-Control-Allow-Credentials header tells the browser whether to include cookies, authorization headers, or TLS client certificates with cross-origin requests.

<IfModule mod_headers.c>
Header set Access-Control-Allow-Credentials "true"
</IfModule>

By default, cross-origin requests do not include credentials. Even if the user is logged into api.example.com, a fetch request from app.example.com will not send the session cookie. Setting this header to true changes that behavior.

The front-end code must also opt in:

// Fetch API
fetch('https://api.example.com/user', {
credentials: 'include' // Required to send cookies
});

// XMLHttpRequest
xhr.withCredentials = true;

Restrictions When Using Credentials

When Access-Control-Allow-Credentials is true, the CORS specification imposes strict restrictions:

  1. Access-Control-Allow-Origin cannot be *. You must specify an exact origin.
  2. Access-Control-Allow-Methods cannot be *. You must list specific methods.
  3. Access-Control-Allow-Headers cannot be *. You must list specific headers.

Wrong approach:

# INVALID combination - wildcard origin with credentials
Header set Access-Control-Allow-Origin "*"
Header set Access-Control-Allow-Credentials "true"

The browser ignores the response and logs an error: "Cannot use wildcard in Access-Control-Allow-Origin when credentials flag is true."

Correct approach:

<IfModule mod_setenvif.c>
<IfModule mod_headers.c>
SetEnvIf Origin "^https://(app\.example\.com|admin\.example\.com)$" CORS_ORIGIN=$0
Header set Access-Control-Allow-Origin "%{CORS_ORIGIN}e" env=CORS_ORIGIN
Header set Access-Control-Allow-Credentials "true" env=CORS_ORIGIN
Header set Vary "Origin" env=CORS_ORIGIN
</IfModule>
</IfModule>
warning

Enabling credentials for cross-origin requests significantly increases the security surface. Only allow credentials from origins you fully trust, and never combine credentials with a wildcard origin. An attacker could create a page that makes authenticated requests to your API on behalf of your users.

Preflight Requests

A preflight request is an automatic OPTIONS request that the browser sends before certain cross-origin requests to ask the server for permission. The browser sends the preflight, examines the response headers, and only proceeds with the actual request if the server's response permits it.

What Triggers a Preflight

Not every cross-origin request triggers a preflight. Simple requests go directly to the server. A request is considered "simple" only if it meets ALL of these criteria:

  • Method is GET, HEAD, or POST.
  • Content-Type (if set) is application/x-www-form-urlencoded, multipart/form-data, or text/plain.
  • No custom headers are set (only CORS-safelisted headers).

Anything outside these criteria triggers a preflight:

Request CharacteristicPreflight?
GET with no custom headersNo
POST with Content-Type: text/plainNo
POST with Content-Type: application/jsonYes
PUT or DELETE requestYes
Any request with Authorization headerYes
Any request with custom headersYes

The Preflight Exchange

Here is what a preflight looks like:

1. Browser sends an OPTIONS request:

OPTIONS /api/data HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization

2. Server responds with allowed methods and headers:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400

3. Browser checks the response: If the requested method and headers are permitted, the browser sends the actual request. If not, the request is blocked and an error is logged in the console.

Handling Preflight in .htaccess

Apache needs to respond correctly to OPTIONS requests. Some configurations return errors for OPTIONS because they are not explicitly handled:

Complete CORS configuration with preflight support
<IfModule mod_headers.c>
<IfModule mod_rewrite.c>
RewriteEngine On

# Handle preflight OPTIONS requests
RewriteCond %{REQUEST_METHOD} OPTIONS
RewriteRule ^(.*)$ $1 [R=204,L]
</IfModule>

# CORS headers
Header set Access-Control-Allow-Origin "https://app.example.com"
Header set Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
Header set Access-Control-Allow-Headers "Content-Type, Authorization, X-Requested-With"

# Cache preflight responses for 24 hours
Header set Access-Control-Max-Age "86400"
</IfModule>

The Access-Control-Max-Age header tells the browser how long (in seconds) to cache the preflight response. During this period, the browser skips the preflight for identical requests, reducing latency. A value of 86400 (24 hours) is common for production APIs.

Common Mistake: Forgetting OPTIONS in Rewrite Rules

If you use a front controller pattern, your RewriteRule might route OPTIONS requests to your application, which may not handle them correctly:

Wrong approach:

# All requests, including OPTIONS, go to index.php
RewriteRule ^(.*)$ /index.php [L]

If index.php does not handle OPTIONS requests properly, preflight requests fail and all non-simple CORS requests are blocked.

Correct approach:

# Handle OPTIONS before the front controller
RewriteCond %{REQUEST_METHOD} OPTIONS
RewriteRule ^(.*)$ $1 [R=204,L]

# Front controller for all other requests
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ /index.php [L]

CORS for Web Fonts

Web fonts are one of the most common resources affected by CORS. The CSS @font-face specification requires that fonts loaded cross-origin must be served with appropriate CORS headers. Without them, the browser blocks the font, and text falls back to a system font.

This typically happens when:

  • Your CSS is served from your main domain but fonts are hosted on a CDN.
  • You use a separate static asset domain (e.g., static.example.com).
  • You serve fonts from a shared asset server used by multiple sites.

Permissive Font CORS (Public Fonts)

If your fonts should be accessible from any origin:

CORS for all font files
<IfModule mod_headers.c>
<FilesMatch "\.(eot|otf|tt[cf]|woff2?)$">
Header set Access-Control-Allow-Origin "*"
</FilesMatch>
</IfModule>

This is safe for fonts because font files themselves do not contain sensitive data. The wildcard allows any website to load your fonts, which is the desired behavior for public CDNs and shared font servers.

Restricted Font CORS (Specific Origins)

If you want to restrict font loading to your own domains:

CORS for fonts from specific origins
<IfModule mod_setenvif.c>
<IfModule mod_headers.c>
<FilesMatch "\.(eot|otf|tt[cf]|woff2?)$">
SetEnvIf Origin "^https://(www\.example\.com|app\.example\.com|blog\.example\.com)$" FONT_ORIGIN=$0
Header set Access-Control-Allow-Origin "%{FONT_ORIGIN}e" env=FONT_ORIGIN
Header set Vary "Origin" env=FONT_ORIGIN
</FilesMatch>
</IfModule>
</IfModule>

Complete CORS Configuration

Here is a production-ready CORS configuration that handles fonts, images, API endpoints, and preflight requests:

Comprehensive CORS configuration
<IfModule mod_setenvif.c>
<IfModule mod_headers.c>

# === Web Fonts: Allow from any origin ===
<FilesMatch "\.(eot|otf|tt[cf]|woff2?)$">
Header set Access-Control-Allow-Origin "*"
</FilesMatch>

# === Images: Allow cross-origin for canvas/WebGL use ===
<FilesMatch "\.(bmp|cur|gif|ico|jpe?g|a?png|svgz?|webp|heic|heif|avif)$">
SetEnvIf Origin ":" IS_CORS
Header set Access-Control-Allow-Origin "*" env=IS_CORS
</FilesMatch>

# === API Endpoints: Restricted to specific origins ===
<Location "/api">
SetEnvIf Origin "^https://(app\.example\.com|admin\.example\.com)$" API_ORIGIN=$0
Header set Access-Control-Allow-Origin "%{API_ORIGIN}e" env=API_ORIGIN
Header set Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" env=API_ORIGIN
Header set Access-Control-Allow-Headers "Content-Type, Authorization" env=API_ORIGIN
Header set Access-Control-Allow-Credentials "true" env=API_ORIGIN
Header set Access-Control-Max-Age "86400" env=API_ORIGIN
Header set Vary "Origin" env=API_ORIGIN
</Location>

# === Resource Timing: Allow performance measurement ===
Header set Timing-Allow-Origin "*"

</IfModule>
</IfModule>

# === Handle Preflight Requests ===
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{REQUEST_METHOD} OPTIONS
RewriteRule ^(.*)$ $1 [R=204,L]
</IfModule>

This configuration applies different CORS policies to different resource types: wide-open access for fonts and images (which are public assets), restricted origin-checked access for API endpoints (which may handle sensitive data), and resource timing headers for performance monitoring.

CORS configuration is about finding the right balance between security and functionality. Use the wildcard for truly public resources like fonts and static images. Use dynamic origin checking for APIs and any endpoint that handles user data. Always include Vary: Origin when the response depends on the requesting origin. And remember to handle preflight OPTIONS requests so that non-simple requests from your front-end applications work correctly.