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 A | URL B | Same Origin? | Reason |
|---|---|---|---|
https://example.com/page | https://example.com/other | Yes | Same scheme, host, port |
https://example.com | http://example.com | No | Different scheme |
https://example.com | https://api.example.com | No | Different host |
https://example.com | https://example.com:8443 | No | Different port |
https://example.com | https://example.org | No | Different 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.
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:
| Scenario | Example |
|---|---|
| API on a different subdomain | app.example.com calling api.example.com |
| Loading web fonts from a CDN | Your site loading fonts from fonts.example-cdn.com |
| Shared image or media assets | Multiple sites loading images from assets.example.com |
| Single-page application with a separate backend | React app on app.com calling API on api.app.com |
| Third-party integrations | Your widget embedded on customer sites needing API access |
| Cross-origin resource timing and performance data | Monitoring 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.
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:
<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:
SetEnvIf Originchecks theOriginrequest header against a regex pattern listing your allowed domains.- If the
Originmatches, the entire matched value is stored in theCORS_ORIGINenvironment variable ($0captures the full match). Header set ... env=CORS_ORIGINonly adds the header if the variable exists (meaning the origin was in the allowed list).Header set Vary "Origin"tells caches that the response varies based on theOriginheader. This is critical. WithoutVary, a CDN or proxy might cache a response withAccess-Control-Allow-Origin: https://app1.example.comand serve it to a request fromapp2.example.com, which would fail.
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:
<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.
| Method | Requires Explicit Listing? | Common Use |
|---|---|---|
GET | No (simple method) | Reading data |
HEAD | No (simple method) | Checking resource metadata |
POST | No (simple method)* | Submitting data |
PUT | Yes | Updating/replacing a resource |
DELETE | Yes | Deleting a resource |
PATCH | Yes | Partially updating a resource |
OPTIONS | Yes (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):
AcceptAccept-LanguageContent-LanguageContent-Type(only forapplication/x-www-form-urlencoded,multipart/form-data, ortext/plain)
Any other header triggers a preflight request and must be listed in Access-Control-Allow-Headers:
| Header | Requires Listing? | Common Use |
|---|---|---|
Content-Type* | Yes (for JSON) | Sending application/json requests |
Authorization | Yes | Bearer tokens, API keys |
X-Requested-With | Yes | AJAX detection (jQuery convention) |
X-Custom-Header | Yes | Any custom header |
Accept | No | Accepted response types |
Accept-Language | No | Language 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:
Access-Control-Allow-Origincannot be*. You must specify an exact origin.Access-Control-Allow-Methodscannot be*. You must list specific methods.Access-Control-Allow-Headerscannot 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>
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, orPOST. - Content-Type (if set) is
application/x-www-form-urlencoded,multipart/form-data, ortext/plain. - No custom headers are set (only CORS-safelisted headers).
Anything outside these criteria triggers a preflight:
| Request Characteristic | Preflight? |
|---|---|
GET with no custom headers | No |
POST with Content-Type: text/plain | No |
POST with Content-Type: application/json | Yes |
PUT or DELETE request | Yes |
Any request with Authorization header | Yes |
| Any request with custom headers | Yes |
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:
<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:
<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:
<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:
<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.