diff --git a/VERSION b/VERSION
index 03412e6..a0f18ea 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.2503.150248
+0.2503.192123
diff --git a/composer.lock b/composer.lock
index 813ab02..6e7116e 100644
--- a/composer.lock
+++ b/composer.lock
@@ -1034,16 +1034,16 @@
         },
         {
             "name": "phpunit/php-code-coverage",
-            "version": "12.0.4",
+            "version": "12.1.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/php-code-coverage.git",
-                "reference": "79e5ef5897068689c7c325554d6df905480ce942"
+                "reference": "d331a5ced3d9a2b917baa9841b2211e72f9e780d"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/79e5ef5897068689c7c325554d6df905480ce942",
-                "reference": "79e5ef5897068689c7c325554d6df905480ce942",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/d331a5ced3d9a2b917baa9841b2211e72f9e780d",
+                "reference": "d331a5ced3d9a2b917baa9841b2211e72f9e780d",
                 "shasum": ""
             },
             "require": {
@@ -1070,7 +1070,7 @@
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-main": "12.0.x-dev"
+                    "dev-main": "12.1.x-dev"
                 }
             },
             "autoload": {
@@ -1099,7 +1099,7 @@
             "support": {
                 "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
                 "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy",
-                "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.0.4"
+                "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.1.0"
             },
             "funding": [
                 {
@@ -1107,7 +1107,7 @@
                     "type": "github"
                 }
             ],
-            "time": "2025-02-25T13:27:48+00:00"
+            "time": "2025-03-17T13:56:07+00:00"
         },
         {
             "name": "phpunit/php-file-iterator",
@@ -1356,16 +1356,16 @@
         },
         {
             "name": "phpunit/phpunit",
-            "version": "12.0.7",
+            "version": "12.0.9",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/phpunit.git",
-                "reference": "2845e49082ef7acc4a71a2ef71bbf32f31da22c9"
+                "reference": "7835bb4276780e0bbb385ce0a777b839e03096db"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/2845e49082ef7acc4a71a2ef71bbf32f31da22c9",
-                "reference": "2845e49082ef7acc4a71a2ef71bbf32f31da22c9",
+                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/7835bb4276780e0bbb385ce0a777b839e03096db",
+                "reference": "7835bb4276780e0bbb385ce0a777b839e03096db",
                 "shasum": ""
             },
             "require": {
@@ -1379,7 +1379,7 @@
                 "phar-io/manifest": "^2.0.4",
                 "phar-io/version": "^3.2.1",
                 "php": ">=8.3",
-                "phpunit/php-code-coverage": "^12.0.4",
+                "phpunit/php-code-coverage": "^12.1.0",
                 "phpunit/php-file-iterator": "^6.0.0",
                 "phpunit/php-invoker": "^6.0.0",
                 "phpunit/php-text-template": "^5.0.0",
@@ -1391,7 +1391,7 @@
                 "sebastian/exporter": "^7.0.0",
                 "sebastian/global-state": "^8.0.0",
                 "sebastian/object-enumerator": "^7.0.0",
-                "sebastian/type": "^6.0.0",
+                "sebastian/type": "^6.0.2",
                 "sebastian/version": "^6.0.0",
                 "staabm/side-effects-detector": "^1.0.5"
             },
@@ -1433,7 +1433,7 @@
             "support": {
                 "issues": "https://github.com/sebastianbergmann/phpunit/issues",
                 "security": "https://github.com/sebastianbergmann/phpunit/security/policy",
-                "source": "https://github.com/sebastianbergmann/phpunit/tree/12.0.7"
+                "source": "https://github.com/sebastianbergmann/phpunit/tree/12.0.9"
             },
             "funding": [
                 {
@@ -1449,7 +1449,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2025-03-07T07:32:22+00:00"
+            "time": "2025-03-19T13:47:33+00:00"
         },
         {
             "name": "sebastian/cli-parser",
@@ -2155,16 +2155,16 @@
         },
         {
             "name": "sebastian/type",
-            "version": "6.0.0",
+            "version": "6.0.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/type.git",
-                "reference": "533fe082889a616f330bcba6f50965135f4f2fab"
+                "reference": "1d7cd6e514384c36d7a390347f57c385d4be6069"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/533fe082889a616f330bcba6f50965135f4f2fab",
-                "reference": "533fe082889a616f330bcba6f50965135f4f2fab",
+                "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/1d7cd6e514384c36d7a390347f57c385d4be6069",
+                "reference": "1d7cd6e514384c36d7a390347f57c385d4be6069",
                 "shasum": ""
             },
             "require": {
@@ -2200,7 +2200,7 @@
             "support": {
                 "issues": "https://github.com/sebastianbergmann/type/issues",
                 "security": "https://github.com/sebastianbergmann/type/security/policy",
-                "source": "https://github.com/sebastianbergmann/type/tree/6.0.0"
+                "source": "https://github.com/sebastianbergmann/type/tree/6.0.2"
             },
             "funding": [
                 {
@@ -2208,7 +2208,7 @@
                     "type": "github"
                 }
             ],
-            "time": "2025-02-07T05:00:19+00:00"
+            "time": "2025-03-18T13:37:31+00:00"
         },
         {
             "name": "sebastian/version",
diff --git a/src/Http/Routing/AccessControl/AccessControl.php b/src/Http/Routing/AccessControl/AccessControl.php
new file mode 100644
index 0000000..196bb18
--- /dev/null
+++ b/src/Http/Routing/AccessControl/AccessControl.php
@@ -0,0 +1,170 @@
+<?php
+// AccessControl.php
+// Created: 2025-03-19
+// Updated: 2025-03-19
+
+namespace Index\Http\Routing\AccessControl;
+
+use Attribute;
+use Closure;
+use ReflectionAttribute;
+use ReflectionFunction;
+use RuntimeException;
+use Index\XArray;
+use Index\Http\Routing\Routes\RouteInfo;
+
+/**
+ * Contains access control info for cross origin requests.
+ */
+#[Attribute(Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD)]
+final class AccessControl {
+    /**
+     * Headers that are always allowed regardless of Access-Control-Allow-Headers.
+     * Also included the CORS ones cus they basically are anyway.
+     *
+     * @see https://developer.mozilla.org/en-US/docs/Glossary/CORS-safelisted_response_header
+     * @var string[]
+     */
+    public const array HEADER_SAFELIST = [
+        'Access-Control-Allow-Headers',
+        'Access-Control-Allow-Methods',
+        'Access-Control-Allow-Origin',
+        'Access-Control-Expose-Headers',
+        'Access-Control-Request-Headers',
+        'Access-Control-Request-Method',
+        'Cache-Control',
+        'Content-Language',
+        'Content-Length',
+        'Content-Type',
+        'Expires',
+        'Last-Modified',
+        'Pragma',
+    ];
+
+    /**
+     * @param bool $allow Whether to allow cross origin requests for this route.
+     *                    If false, everything else will be ignored since there's no need to specify it.
+     *                    Provided if necessary to explicitly deny.
+     * @param bool $credentials Whether to permit the client to include the credentials for our domain in requests using withCredentials = true.
+     * @param string[]|true $allowMethods What additional methods to allow for this route. true function as wildcard.
+     *                                    Setting $allow to true will already include the $method provided in the RouteInfo constructor.
+     * @param string[]|true $allowHeaders What headers to allow for this route. true functions as wildcard.
+     * @param string[]|true $exposeHeaders What headers to expose to the remote origin. true function as wildcard, false as an explicit empty list.
+     *                                     In addition to Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified and Pragma.
+     *                                     false and empty array are seemingly swapped in terms of functionality at first glance
+     *                                     but i think having false represent an explicit Nothing will be nicer in the long run since it changes the type entirely
+     */
+    public function __construct(
+        public private(set) bool $allow = true,
+        public private(set) bool $credentials = false,
+        public private(set) array|true $allowMethods = [],
+        public private(set) array|true $allowHeaders = [],
+        public private(set) array|true $exposeHeaders = [],
+    ) {}
+
+    /**
+     * Verifies if provided headers are allowed.
+     *
+     * @param string[] $headers Request headers.
+     * @return bool
+     */
+    public function verifyHeadersAllowed(array $headers): bool {
+        return $this->allowHeaders === true || XArray::all($headers, fn($name) => (
+            XArray::any(self::HEADER_SAFELIST, fn($safeName) => strcasecmp($safeName, $name) === 0)
+            || XArray::any($this->allowHeaders, fn($allowName) => strcasecmp($allowName, $name) === 0)
+        ));
+    }
+
+    /**
+     * Reads methods on a provided Closure and creates or amends an AccessControl object.
+     *
+     * @param Closure $closure Closure to read attributes from.
+     * @param ?AccessControl $info Base access control info object.
+     * @return ?AccessControl
+     */
+    public static function read(Closure $closure, ?AccessControl $info): ?AccessControl {
+        $attrs = (new ReflectionFunction($closure))->getAttributes(self::class, ReflectionAttribute::IS_INSTANCEOF);
+        $attr = array_shift($attrs);
+        if($attr === null)
+            return $info;
+
+        $attr = $attr->newInstance();
+
+        // explicit allow === false always takes precedence over everything else
+        if(!$attr->allow)
+            return $attr;
+
+        if($info !== null) {
+            // see the above
+            if(!$info->allow)
+                return $info;
+
+            // false is default so true takes precedence
+            // maybe not the best strat but its the one you're getting
+            if($info->credentials)
+                $attr->credentials = true;
+
+            if($attr->allowMethods !== true)
+                $attr->allowMethods = $info->allowMethods === true
+                    ? true : array_values(array_unique(array_merge($attr->allowMethods, $info->allowMethods)));
+
+            if($attr->allowHeaders !== true)
+                $attr->allowHeaders = $info->allowHeaders === true
+                    ? true : array_values(array_unique(array_merge($attr->allowHeaders, $info->allowHeaders)));
+
+            if($attr->exposeHeaders !== true)
+                $attr->exposeHeaders = $info->exposeHeaders === true
+                    ? true : array_values(array_unique(array_merge($attr->exposeHeaders, $info->exposeHeaders)));
+        }
+
+        return $attr;
+    }
+
+    /**
+     * Aggregrates access control declarations for multiple
+     *
+     * @param array<object{info: RouteInfo}|RouteInfo> $routes Routes that were caught for this preflight request.
+     * @param string $requestMethod Intended request method for this preflight.
+     * @return AccessControl Aggregated access control info.
+     */
+    public static function aggregate(array $routes, string $requestMethod): AccessControl {
+        $allow = false;
+        $credentials = false;
+        $allowMethods = [];
+        $allowHeaders = [];
+        $exposeHeaders = [];
+
+        foreach($routes as $routeInfo) {
+            if(!($routeInfo instanceof RouteInfo)) {
+                if(property_exists($routeInfo, 'info') && $routeInfo->info instanceof RouteInfo)
+                    $routeInfo = $routeInfo->info;
+                else continue;
+            }
+
+            $accessControl = $routeInfo->accessControl;
+            if($accessControl?->allow !== true)
+                continue;
+
+            $allow = true;
+
+            if($allowMethods !== true)
+                if($accessControl->allowMethods === true) {
+                    $allowMethods = true;
+                } else {
+                    $allowMethods[] = $routeInfo->method;
+                    if(!empty($accessControl->allowMethods))
+                        $allowMethods = array_merge($allowMethods, $accessControl->allowMethods);
+                }
+
+            if($routeInfo->method === $requestMethod) {
+                if($accessControl->credentials)
+                    $credentials = true;
+
+                $allowHeaders = $accessControl->allowHeaders;
+                $exposeHeaders = $accessControl->exposeHeaders;
+            }
+        }
+
+        return new AccessControl($allow, $credentials, $allowMethods, $allowHeaders, $exposeHeaders);
+    }
+}
diff --git a/src/Http/Routing/AccessControl/AccessControlHandler.php b/src/Http/Routing/AccessControl/AccessControlHandler.php
new file mode 100644
index 0000000..538c9eb
--- /dev/null
+++ b/src/Http/Routing/AccessControl/AccessControlHandler.php
@@ -0,0 +1,52 @@
+<?php
+// AccessControlHandler.php
+// Created: 2025-03-19
+// Updated: 2025-03-19
+
+namespace Index\Http\Routing\AccessControl;
+
+use Index\XArray;
+use Index\Http\HttpUri;
+use Index\Http\Routing\HandlerContext;
+use Index\Http\Routing\Routes\RouteInfo;
+
+/**
+ * CORS handler interface.
+ */
+interface AccessControlHandler {
+    /**
+     * Aggregates info needed for built-in HTTP OPTIONS method handler.
+     *
+     * @param HandlerContext $context Route handler context.
+     * @param AccessControl $accessControl Aggregated access control rules.
+     * @param RouteInfo[] $routes Routes that this OPTIONS request concern.
+     * @param HttpUri $requestOrigin HTTP request Origin header value.
+     * @param string $requestMethod Intended HTTP request method.
+     * @param string[]|null $requestHeaders Intended HTTP request headers.
+     * @return AccessControlPreflight
+     */
+    public function handlePreflight(
+        HandlerContext $context,
+        AccessControl $accessControl,
+        array $routes,
+        HttpUri $requestOrigin,
+        string $requestMethod,
+        array|null $requestHeaders,
+    ): AccessControlPreflight;
+
+    /**
+     * Adds CORS headers to a request if applicable.
+     *
+     * @param HandlerContext $context Route handler context.
+     * @param RouteInfo $routeInfo Route info.
+     * @param AccessControl $accessControl Access control for the given route info.
+     * @param HttpUri $requestOrigin HTTP request Origin header value.
+     * @return AccessControlResult
+     */
+    public function handleRequest(
+        HandlerContext $context,
+        RouteInfo $routeInfo,
+        AccessControl $accessControl,
+        HttpUri $requestOrigin,
+    ): AccessControlResult;
+}
diff --git a/src/Http/Routing/AccessControl/AccessControlPreflight.php b/src/Http/Routing/AccessControl/AccessControlPreflight.php
new file mode 100644
index 0000000..363b06e
--- /dev/null
+++ b/src/Http/Routing/AccessControl/AccessControlPreflight.php
@@ -0,0 +1,69 @@
+<?php
+// AccessControlPreflight.php
+// Created: 2025-03-19
+// Updated: 2025-03-19
+
+namespace Index\Http\Routing\AccessControl;
+
+use Index\Http\HttpResponseBuilder;
+
+/**
+ * Contains the result for a preflight request.
+ */
+class AccessControlPreflight {
+    /**
+     * @param string|bool $origin Origin to use in the Access-Control-Allow-Origin header, true for *, false for deny.
+     * @param string[]|true $methods Methods to use in the Access-Control-Allow-Methods header, true for *.
+     * @param string[]|true|null $headers Headers to use in the Access-Control-Allow-Headers header, true for *, null for none.
+     * @param bool $credentials Value for the Access-Control-Allow-Credentials header.
+     * @param ?int $ttl Override value for Access-Control-Max-Age header, null for default supplied in AccessControlHandler constructor.
+     */
+    public function __construct(
+        public private(set) string|bool $origin = false,
+        public private(set) array|true $methods = [],
+        public private(set) array|true|null $headers = null,
+        public private(set) bool $credentials = false,
+        public private(set) ?int $ttl = null,
+    ) {}
+
+    /**
+     * Applies this collection of headers to a HttpResponseBuilder.
+     *
+     * @param HttpResponseBuilder $response Response to apply to.
+     * @param int $ttl Default TTL value, if $ttl is specified in the constructor it should be overridden.
+     */
+    public function applyPreflight(HttpResponseBuilder $response, int $ttl): void {
+        if($this->origin === false)
+            return;
+
+        if($this->credentials)
+            $response->setHeader('Access-Control-Allow-Credentials', 'true');
+
+        if($this->headers !== null) {
+            if($this->headers === true)
+                $response->setHeader('Access-Control-Allow-Headers', '*');
+            else {
+                $headers = array_unique($this->headers);
+                sort($headers);
+                $response->setHeader('Access-Control-Allow-Headers', implode(', ', $headers));
+            }
+        }
+
+        if($this->methods === true) {
+            $response->setHeader('Access-Control-Allow-Methods', '*');
+        } elseif(!empty($this->methods)) {
+            $methods = array_unique($this->methods);
+            sort($methods);
+            $response->setHeader('Access-Control-Allow-Methods', implode(', ', $methods));
+        }
+
+        if($this->origin === true) {
+            $response->setHeader('Access-Control-Allow-Origin', '*');
+        } else {
+            $response->addVary('Origin');
+            $response->setHeader('Access-Control-Allow-Origin', $this->origin);
+        }
+
+        $response->setHeader('Access-Control-Max-Age', (string)($this->ttl ?? $ttl));
+    }
+}
diff --git a/src/Http/Routing/AccessControl/AccessControlResult.php b/src/Http/Routing/AccessControl/AccessControlResult.php
new file mode 100644
index 0000000..afbb1ab
--- /dev/null
+++ b/src/Http/Routing/AccessControl/AccessControlResult.php
@@ -0,0 +1,54 @@
+<?php
+// AccessControlResult.php
+// Created: 2025-03-19
+// Updated: 2025-03-19
+
+namespace Index\Http\Routing\AccessControl;
+
+use Index\Http\HttpResponseBuilder;
+
+/**
+ * Contains the result for a normal request.
+ */
+class AccessControlResult {
+    /**
+     * @param string|bool $origin Origin to use in the Access-Control-Allow-Origin header, true for *, false for deny.
+     * @param string[]|bool $headers Headers to use in the Access-Control-Expose-Headers header, true for *, false for none.
+     * @param bool $credentials Value for the Access-Control-Allow-Credentials header.
+     */
+    public function __construct(
+        public private(set) string|bool $origin = false,
+        public private(set) array|bool $headers = [],
+        public private(set) bool $credentials = false,
+    ) {}
+
+    /**
+     * Applies this collection of headers to a HttpResponseBuilder.
+     *
+     * @param HttpResponseBuilder $response Response to apply to.
+     */
+    public function applyResult(HttpResponseBuilder $response): void {
+        if($this->origin === false)
+            return;
+
+        if($this->credentials)
+            $response->setHeader('Access-Control-Allow-Credentials', 'true');
+
+        if($this->origin === true) {
+            $response->setHeader('Access-Control-Allow-Origin', '*');
+        } else {
+            $response->addVary('Origin');
+            $response->setHeader('Access-Control-Allow-Origin', $this->origin);
+        }
+
+        if($this->headers === true)
+            $response->setHeader('Access-Control-Expose-Headers', '*');
+        elseif($this->headers === false)
+            $response->setHeader('Access-Control-Expose-Headers', '');
+        elseif(!empty($this->headers)) {
+            $headers = array_unique($this->headers);
+            sort($headers);
+            $response->setHeader('Access-Control-Expose-Headers', implode(', ', $headers));
+        }
+    }
+}
diff --git a/src/Http/Routing/AccessControl/SimpleAccessControlHandler.php b/src/Http/Routing/AccessControl/SimpleAccessControlHandler.php
new file mode 100644
index 0000000..4801b78
--- /dev/null
+++ b/src/Http/Routing/AccessControl/SimpleAccessControlHandler.php
@@ -0,0 +1,64 @@
+<?php
+// SimpleAccessControlHandler.php
+// Created: 2025-03-19
+// Updated: 2025-03-19
+
+namespace Index\Http\Routing\AccessControl;
+
+use Index\XArray;
+use Index\Http\HttpUri;
+use Index\Http\Routing\HandlerContext;
+use Index\Http\Routing\Routes\RouteInfo;
+
+/**
+ * Base CORS handler.
+ *
+ * Override checkAccess to make this not function as a wildcard.
+ */
+class SimpleAccessControlHandler implements AccessControlHandler {
+    public function checkAccess(
+        HandlerContext $context,
+        AccessControl $accessControl,
+        HttpUri $origin,
+        ?RouteInfo $routeInfo = null,
+    ): string|bool {
+        return $accessControl->credentials ? (string)$origin : true;
+    }
+
+    public function handlePreflight(
+        HandlerContext $context,
+        AccessControl $accessControl,
+        array $routes,
+        HttpUri $requestOrigin,
+        string $requestMethod,
+        array|null $requestHeaders,
+    ): AccessControlPreflight {
+        $requestOrigin = $this->checkAccess($context, $accessControl, $requestOrigin);
+        if($requestOrigin === false)
+            return new AccessControlPreflight;
+
+        return new AccessControlPreflight(
+            $requestOrigin,
+            $accessControl->allowMethods,
+            $requestHeaders === null ? null : ($accessControl->allowHeaders === true ? true : $requestHeaders),
+            $accessControl->credentials,
+        );
+    }
+
+    public function handleRequest(
+        HandlerContext $context,
+        RouteInfo $routeInfo,
+        AccessControl $accessControl,
+        HttpUri $requestOrigin,
+    ): AccessControlResult {
+        $requestOrigin = $this->checkAccess($context, $accessControl, $requestOrigin, $routeInfo);
+        if($requestOrigin === false)
+            return new AccessControlResult;
+
+        return new AccessControlResult(
+            $requestOrigin,
+            $accessControl->exposeHeaders,
+            $accessControl->credentials,
+        );
+    }
+}
diff --git a/src/Http/Routing/Router.php b/src/Http/Routing/Router.php
index 3dae8a4..eaca839 100644
--- a/src/Http/Routing/Router.php
+++ b/src/Http/Routing/Router.php
@@ -1,7 +1,7 @@
 <?php
 // Router.php
 // Created: 2024-03-28
-// Updated: 2025-03-15
+// Updated: 2025-03-19
 
 namespace Index\Http\Routing;
 
@@ -9,8 +9,10 @@ use InvalidArgumentException;
 use JsonSerializable;
 use RuntimeException;
 use Stringable;
+use Index\XArray;
 use Index\Bencode\{Bencode,BencodeSerializable};
-use Index\Http\HttpRequest;
+use Index\Http\{HttpRequest,HttpUri};
+use Index\Http\Routing\AccessControl\{AccessControl,AccessControlHandler,SimpleAccessControlHandler};
 use Index\Http\Routing\ErrorHandling\{ErrorHandler,HtmlErrorHandler,PlainErrorHandler};
 use Index\Http\Routing\Filters\FilterInfo;
 use Index\Http\Routing\Processors\{ProcessorInfo,ProcessorTarget};
@@ -46,22 +48,32 @@ class Router implements RequestHandlerInterface {
     private array $postprocs = [];
 
     /**
-     * @param ErrorHandler|'html'|'plain' $errorHandler Error handling to use for error responses with an empty body. 'html' for the default HTML implementation, 'plain' for the plaintext implementation.
-     * @param bool $registerDefaultProcessors Whether the default processors should be registered.
+     * Access control handler instance.
      */
-    public function __construct(
-        ErrorHandler|string $errorHandler = 'html',
-        bool $registerDefaultProcessors = true
-    ) {
-        $this->setErrorHandler($errorHandler);
-        if($registerDefaultProcessors)
-            $this->register(new RouterProcessors);
-    }
+    public private(set) AccessControlHandler $accessControlHandler;
 
     /**
      * Error handler instance.
      */
-    public ErrorHandler $errorHandler;
+    public private(set) ErrorHandler $errorHandler;
+
+    /**
+     * @param ErrorHandler|'html'|'plain' $errorHandler Error handling to use for error responses with an empty body. 'html' for the default HTML implementation, 'plain' for the plaintext implementation.
+     * @param ?AccessControlHandler $accessControlHandler Handler for CORS requests.
+     * @param int $accessControlTtl Default Access-Control-Max-Age value.
+     * @param bool $registerDefaultProcessors Whether the default processors should be registered.
+     */
+    public function __construct(
+        ErrorHandler|string $errorHandler = 'html',
+        ?AccessControlHandler $accessControlHandler = null,
+        public private(set) int $accessControlTtl = 300,
+        bool $registerDefaultProcessors = true
+    ) {
+        $this->setErrorHandler($errorHandler);
+        $this->accessControlHandler = $accessControlHandler ?? new SimpleAccessControlHandler;
+        if($registerDefaultProcessors)
+            $this->register(new RouterProcessors);
+    }
 
     /**
      * Sets an error handler.
@@ -250,14 +262,17 @@ class Router implements RequestHandlerInterface {
             }
         }
 
+        /** @var array<string, object{info: RouteInfo, matches: ?array<int|string, mixed>}> */
         $methods = [];
         foreach($this->routes as $routeInfo) {
             $match = $routeInfo->matcher->match($context->request->uri);
-            if($match !== false)
+            if($match !== false) {
+                $routeInfo->readAccessControl();
                 $methods[$routeInfo->method] = (object)[
                     'info' => $routeInfo,
                     'matches' => is_array($match) ? $match : null,
                 ];
+            }
         }
 
         if(empty($methods)) {
@@ -276,14 +291,39 @@ class Router implements RequestHandlerInterface {
                 $allow[] = 'OPTIONS';
                 if(in_array('GET', $allow))
                     $allow[] = 'HEAD';
-
                 $context->response->setAllow($allow);
                 $context->response->reasonPhrase = '';
 
                 if($context->request->method === 'OPTIONS') {
-                    // this should include CORS stuff
-                    // should have an entry in RouteInfo which get aggregated here, but also attributes
-                    $context->response->statusCode = 204;
+                    $context->response->statusCode = 200;
+                    $context->response->setHeader('Content-Length', '0');
+
+                    if($context->request->hasHeader('Origin')) {
+                        $requestMethod = strtoupper($context->request->getHeaderLine('Access-Control-Request-Method'));
+                        $routeInfos = array_map(fn($item) => $item->info, $methods);
+                        $accessControl = AccessControl::aggregate($routeInfos, $requestMethod);
+
+                        if($accessControl->allow) {
+                            if($context->request->hasHeader('Access-Control-Request-Headers')) {
+                                $requestHeaders = trim($context->request->getHeaderLine('Access-Control-Request-Headers'));
+                                $requestHeaders = $requestHeaders === '' ? [] : XArray::select(
+                                    explode(',', $requestHeaders),
+                                    fn($part) => trim($part)
+                                );
+                            } else
+                                $requestHeaders = null;
+
+                            if($requestHeaders === null || $accessControl->verifyHeadersAllowed($requestHeaders))
+                                $this->accessControlHandler->handlePreflight(
+                                    $context,
+                                    $accessControl,
+                                    $routeInfos,
+                                    HttpUri::createUri($context->request->getHeaderLine('Origin')),
+                                    $requestMethod,
+                                    $requestHeaders,
+                                )->applyPreflight($context->response, $this->accessControlTtl);
+                        }
+                    }
                 } else {
                     $context->response->statusCode = 405;
                     $this->errorHandler->handle($context);
@@ -295,6 +335,18 @@ class Router implements RequestHandlerInterface {
         }
 
         $route = $methods[$context->request->method];
+
+        // if you're overriding OPTIONS you're on your own
+        if($context->request->method !== 'OPTIONS'
+            && $context->request->hasHeader('Origin')
+            && $route->info->accessControl?->allow === true)
+            $this->accessControlHandler->handleRequest(
+                $context,
+                $route->info,
+                $route->info->accessControl,
+                HttpUri::createUri($context->request->getHeaderLine('Origin')),
+            )->applyResult($context->response);
+
         $route->info->readProcessors();
 
         foreach($route->info->preprocs as $name => $args) {
diff --git a/src/Http/Routing/Routes/RouteInfo.php b/src/Http/Routing/Routes/RouteInfo.php
index 19dd78a..6213d4e 100644
--- a/src/Http/Routing/Routes/RouteInfo.php
+++ b/src/Http/Routing/Routes/RouteInfo.php
@@ -1,12 +1,13 @@
 <?php
 // RouteInfo.php
 // Created: 2025-03-02
-// Updated: 2025-03-07
+// Updated: 2025-03-19
 
 namespace Index\Http\Routing\Routes;
 
 use Closure;
 use InvalidArgumentException;
+use Index\Http\Routing\AccessControl\AccessControl;
 use Index\Http\Routing\Processors\ProcessAttribute;
 use Index\Http\Routing\UriMatchers\{ExactPathUriMatcher,PatternPathUriMatcher,UriMatcher};
 
@@ -33,6 +34,12 @@ class RouteInfo {
      */
     public private(set) array $postprocs = [];
 
+    /**
+     * CORS info for this route.
+     */
+    public ?AccessControl $accessControl = null;
+
+    private bool $aclRead = false;
     private bool $processorsRead = false;
 
     /**
@@ -97,6 +104,17 @@ class RouteInfo {
             $this->before($name, $args);
     }
 
+    /**
+     * Reads access control attributes and merges them with the existing declarations.
+     */
+    public function readAccessControl(): void {
+        if($this->aclRead)
+            return;
+        $this->aclRead = true;
+
+        $this->accessControl = AccessControl::read($this->handler, $this->accessControl);
+    }
+
     /**
      * Creates RouteInfo instance using ExactPathUriMatcher.
      *
diff --git a/tests/RouterTest.php b/tests/RouterTest.php
index 63a139b..1511e23 100644
--- a/tests/RouterTest.php
+++ b/tests/RouterTest.php
@@ -1,7 +1,7 @@
 <?php
 // RouterTest.php
 // Created: 2022-01-20
-// Updated: 2025-03-15
+// Updated: 2025-03-19
 
 declare(strict_types=1);
 
@@ -10,6 +10,7 @@ use PHPUnit\Framework\Attributes\CoversClass;
 use Index\Http\{HttpHeaders,HttpResponseBuilder,HttpRequest,HttpUri};
 use Index\Http\Content\{FormContent,MultipartFormContent,UrlEncodedFormContent};
 use Index\Http\Routing\{HandlerContext,RouteHandler,RouteHandlerCommon,Router};
+use Index\Http\Routing\AccessControl\{AccessControl};
 use Index\Http\Routing\Filters\{FilterInfo,PrefixFilter};
 use Index\Http\Routing\Processors\{After,Before,Postprocessor,Preprocessor,ProcessorInfo};
 use Index\Http\Routing\Routes\{ExactRoute,PatternRoute,RouteInfo};
@@ -27,6 +28,7 @@ use Index\Http\Streams\Stream;
 #[CoversClass(RouteHandlerCommon::class)]
 #[CoversClass(PatternRoute::class)]
 #[CoversClass(FilterInfo::class)]
+#[CoversClass(AccessControl::class)]
 final class RouterTest extends TestCase {
     public function testRouter(): void {
         $router1 = new Router;
@@ -38,8 +40,8 @@ final class RouterTest extends TestCase {
         $router1->route(RouteInfo::exact('PUT', '/', fn() => 'put'));
         $router1->route(RouteInfo::exact('CUSTOM', '/', fn() => 'wacky'));
 
-        $this->assertEquals('get', (string)$router1->handle(HttpRequest::createRequestWithoutBody('GET', '/'))->getBody());
-        $this->assertEquals('wacky', (string)$router1->handle(HttpRequest::createRequestWithoutBody('CUSTOM', '/'))->getBody());
+        $this->assertSame('get', (string)$router1->handle(HttpRequest::createRequestWithoutBody('GET', '/'))->getBody());
+        $this->assertSame('wacky', (string)$router1->handle(HttpRequest::createRequestWithoutBody('CUSTOM', '/'))->getBody());
 
         $router1->filter(FilterInfo::prefix('/', function() { /* this one intentionally does nothing */ }));
 
@@ -53,7 +55,7 @@ final class RouterTest extends TestCase {
         $router1->route(RouteInfo::pattern('GET', '#^/user/([A-Za-z0-9]+)$#uD', fn(string $user) => $user));
         $router1->route(RouteInfo::pattern('GET', '#^/user/([A-Za-z0-9]+)/below$#uD', fn(string $user) => 'below ' . $user));
 
-        $this->assertEquals('warioware below static', (string)$router1->handle(HttpRequest::createRequestWithoutBody('GET', '/user/static/below'))->getBody());
+        $this->assertSame('warioware below static', (string)$router1->handle(HttpRequest::createRequestWithoutBody('GET', '/user/static/below'))->getBody());
 
         $router2 = new Router;
         $router2->filter(FilterInfo::prefix('/', fn() => 'meow'));
@@ -61,7 +63,7 @@ final class RouterTest extends TestCase {
         $router2->route(RouteInfo::exact('GET', '/contact', fn() => 'contact page'));
         $router2->route(RouteInfo::exact('GET', '/25252', fn() => 'numeric test'));
 
-        $this->assertEquals('meow', (string)$router2->handle(HttpRequest::createRequestWithoutBody('GET', '/rules'))->getBody());
+        $this->assertSame('meow', (string)$router2->handle(HttpRequest::createRequestWithoutBody('GET', '/rules'))->getBody());
     }
 
     public function testAttribute(): void {
@@ -117,13 +119,13 @@ final class RouterTest extends TestCase {
 
         $router->register($handler);
 
-        $this->assertEquals('index', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/'))->getBody());
-        $this->assertEquals('avatar', (string)$router->handle(HttpRequest::createRequestWithoutBody('POST', '/avatar'))->getBody());
-        $this->assertEquals('static', (string)$router->handle(HttpRequest::createRequestWithoutBody('PUT', '/static'))->getBody());
-        $this->assertEquals('meow', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/meow'))->getBody());
-        $this->assertEquals('meow', (string)$router->handle(HttpRequest::createRequestWithoutBody('POST', '/meow'))->getBody());
-        $this->assertEquals('profile of Cool134', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/profile/Cool134'))->getBody());
-        $this->assertEquals('still the profile of Cool134', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/profile-but-raw/Cool134'))->getBody());
+        $this->assertSame('index', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/'))->getBody());
+        $this->assertSame('avatar', (string)$router->handle(HttpRequest::createRequestWithoutBody('POST', '/avatar'))->getBody());
+        $this->assertSame('static', (string)$router->handle(HttpRequest::createRequestWithoutBody('PUT', '/static'))->getBody());
+        $this->assertSame('meow', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/meow'))->getBody());
+        $this->assertSame('meow', (string)$router->handle(HttpRequest::createRequestWithoutBody('POST', '/meow'))->getBody());
+        $this->assertSame('profile of Cool134', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/profile/Cool134'))->getBody());
+        $this->assertSame('still the profile of Cool134', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/profile-but-raw/Cool134'))->getBody());
     }
 
     public function testEEPROMSituation(): void {
@@ -132,8 +134,8 @@ final class RouterTest extends TestCase {
         $router->route(RouteInfo::pattern('DELETE', '#^/uploads/([A-Za-z0-9\-_]+)$#uD', fn(string $id) => "Delete {$id}"));
 
         // make sure both GET and DELETE are able to execute with a different pattern
-        $this->assertEquals('Get AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/uploads/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'))->getBody());
-        $this->assertEquals('Delete BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', (string)$router->handle(HttpRequest::createRequestWithoutBody('DELETE', '/uploads/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'))->getBody());
+        $this->assertSame('Get AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/uploads/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'))->getBody());
+        $this->assertSame('Delete BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', (string)$router->handle(HttpRequest::createRequestWithoutBody('DELETE', '/uploads/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'))->getBody());
     }
 
     public function testFilterInterceptionOnRoot(): void {
@@ -142,9 +144,9 @@ final class RouterTest extends TestCase {
         $router->route(RouteInfo::exact('GET', '/', fn() => 'unexpected'));
         $router->route(RouteInfo::exact('GET', '/test', fn() => 'also unexpected'));
 
-        $this->assertEquals('expected', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/'))->getBody());
-        $this->assertEquals('expected', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/test'))->getBody());
-        $this->assertEquals('expected', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/error'))->getBody());
+        $this->assertSame('expected', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/'))->getBody());
+        $this->assertSame('expected', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/test'))->getBody());
+        $this->assertSame('expected', (string)$router->handle(HttpRequest::createRequestWithoutBody('GET', '/error'))->getBody());
     }
 
     public function testDefaultOptionsImplementation(): void {
@@ -154,9 +156,10 @@ final class RouterTest extends TestCase {
         $router->route(RouteInfo::exact('PATCH', '/test', fn() => 'patch'));
 
         $response = $router->handle(HttpRequest::createRequestWithoutBody('OPTIONS', '/test'));
-        $this->assertEquals(204, $response->getStatusCode());
-        $this->assertEquals('No Content', $response->getReasonPhrase());
-        $this->assertEquals('GET, HEAD, OPTIONS, PATCH, POST', $response->getHeaderLine('Allow'));
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertSame('OK', $response->getReasonPhrase());
+        $this->assertSame('0', $response->getHeaderLine('Content-Length'));
+        $this->assertSame('GET, HEAD, OPTIONS, PATCH, POST', $response->getHeaderLine('Allow'));
     }
 
     public function testDefaultProcessorsNoRegister(): void {
@@ -238,69 +241,69 @@ final class RouterTest extends TestCase {
                 return '<!doctype html><h1>ensuring autodetect gets skipped</h1>';
             }
 
+            /** @return array{wow: string} */
             #[After('output:json', flags: 0)]
             #[ExactRoute('GET', '/output/json')]
-            /** @return array{wow: string} */
             public function testOutputJson(): array {
                 return ['wow' => 'objects?? / epic!!'];
             }
 
+            /** @return array{benben: int} */
             #[After('output:bencode')]
             #[ExactRoute('GET', '/output/bencode')]
-            /** @return array{benben: int} */
             public function testOutputBencode(): array {
                 return ['benben' => 12345];
             }
         });
 
         $response = $router->handle(HttpRequest::createRequestWithoutBody('GET', '/output/stream'));
-        $this->assertEquals('application/octet-stream', $response->getHeaderLine('Content-Type'));
-        $this->assertEquals('<!doctype html><h1>ensuring autodetect gets skipped</h1>', (string)$response->getBody());
+        $this->assertSame('application/octet-stream', $response->getHeaderLine('Content-Type'));
+        $this->assertSame('<!doctype html><h1>ensuring autodetect gets skipped</h1>', (string)$response->getBody());
 
         $response = $router->handle(HttpRequest::createRequestWithoutBody('GET', '/output/plain'));
-        $this->assertEquals('text/plain;charset=utf-8', $response->getHeaderLine('Content-Type'));
-        $this->assertEquals('<!doctype html><h1>ensuring autodetect gets skipped</h1>', (string)$response->getBody());
+        $this->assertSame('text/plain;charset=utf-8', $response->getHeaderLine('Content-Type'));
+        $this->assertSame('<!doctype html><h1>ensuring autodetect gets skipped</h1>', (string)$response->getBody());
 
         $response = $router->handle(HttpRequest::createRequestWithoutBody('GET', '/output/html'));
-        $this->assertEquals('text/html;charset=us-ascii', $response->getHeaderLine('Content-Type'));
-        $this->assertEquals('<?xml idk how xml opens the prefix is enough><beans></beans>', (string)$response->getBody());
+        $this->assertSame('text/html;charset=us-ascii', $response->getHeaderLine('Content-Type'));
+        $this->assertSame('<?xml idk how xml opens the prefix is enough><beans></beans>', (string)$response->getBody());
 
         $response = $router->handle(HttpRequest::createRequestWithoutBody('GET', '/output/xml'));
-        $this->assertEquals('application/xml', $response->getHeaderLine('Content-Type'));
-        $this->assertEquals('soup', (string)$response->getBody());
+        $this->assertSame('application/xml', $response->getHeaderLine('Content-Type'));
+        $this->assertSame('soup', (string)$response->getBody());
 
         $response = $router->handle(HttpRequest::createRequestWithoutBody('GET', '/output/css'));
-        $this->assertEquals('text/css', $response->getHeaderLine('Content-Type'));
-        $this->assertEquals('<!doctype html><h1>ensuring autodetect gets skipped</h1>', (string)$response->getBody());
+        $this->assertSame('text/css', $response->getHeaderLine('Content-Type'));
+        $this->assertSame('<!doctype html><h1>ensuring autodetect gets skipped</h1>', (string)$response->getBody());
 
         $response = $router->handle(HttpRequest::createRequestWithoutBody('GET', '/output/js'));
-        $this->assertEquals('application/javascript', $response->getHeaderLine('Content-Type'));
-        $this->assertEquals('<!doctype html><h1>ensuring autodetect gets skipped</h1>', (string)$response->getBody());
+        $this->assertSame('application/javascript', $response->getHeaderLine('Content-Type'));
+        $this->assertSame('<!doctype html><h1>ensuring autodetect gets skipped</h1>', (string)$response->getBody());
 
         $response = $router->handle(HttpRequest::createRequestWithoutBody('GET', '/output/json'));
-        $this->assertEquals('application/json', $response->getHeaderLine('Content-Type'));
-        $this->assertEquals('{"wow":"objects?? \/ epic!!"}', (string)$response->getBody());
+        $this->assertSame('application/json', $response->getHeaderLine('Content-Type'));
+        $this->assertSame('{"wow":"objects?? \/ epic!!"}', (string)$response->getBody());
 
         $response = $router->handle(HttpRequest::createRequestWithoutBody('GET', '/output/bencode'));
-        $this->assertEquals('application/x-bittorrent', $response->getHeaderLine('Content-Type'));
-        $this->assertEquals('d6:benbeni12345ee', (string)$response->getBody());
+        $this->assertSame('application/x-bittorrent', $response->getHeaderLine('Content-Type'));
+        $this->assertSame('d6:benbeni12345ee', (string)$response->getBody());
 
         $response = $router->handle(HttpRequest::createRequestWithoutBody('POST', '/optional-form'));
-        $this->assertEquals(200, $response->getStatusCode());
-        $this->assertEquals('none', (string)$response->getBody());
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertSame('none', (string)$response->getBody());
 
         $response = $router->handle(HttpRequest::createRequestWithBody('POST', '/optional-form', [], Stream::createStream('test=mewow')));
-        $this->assertEquals(200, $response->getStatusCode());
-        $this->assertEquals('none', (string)$response->getBody());
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertSame('none', (string)$response->getBody());
 
         $response = $router->handle(HttpRequest::createRequestWithBody('POST', '/optional-form', ['Content-Type' => ['application/x-www-form-urlencoded']], Stream::createStream('test=mewow')));
-        $this->assertEquals(200, $response->getStatusCode());
-        $this->assertEquals('urlencoded:mewow', (string)$response->getBody());
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertSame('urlencoded:mewow', (string)$response->getBody());
 
         // an empty string is valid too
         $response = $router->handle(HttpRequest::createRequestWithBody('POST', '/optional-form', ['Content-Type' => ['application/x-www-form-urlencoded']], Stream::createStream('')));
-        $this->assertEquals(200, $response->getStatusCode());
-        $this->assertEquals('urlencoded:', (string)$response->getBody());
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertSame('urlencoded:', (string)$response->getBody());
 
         $response = $router->handle(HttpRequest::createRequestWithBody('POST', '/optional-form', ['Content-Type' => ['multipart/form-data; boundary="--soap12345"']], Stream::createStream(implode("\r\n", [
             '----soap12345',
@@ -310,25 +313,25 @@ final class RouterTest extends TestCase {
             '----soap12345--',
             '',
         ]))));
-        $this->assertEquals(200, $response->getStatusCode());
-        $this->assertEquals('multipart:wowof', (string)$response->getBody());
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertSame('multipart:wowof', (string)$response->getBody());
 
         $response = $router->handle(HttpRequest::createRequestWithBody('POST', '/optional-form', ['Content-Type' => ['multipart/form-data; boundary="--soap12345"']], Stream::createStream(implode("\r\n", [
             '----soap12345--',
             '',
         ]))));
-        $this->assertEquals(200, $response->getStatusCode());
-        $this->assertEquals('multipart:', (string)$response->getBody());
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertSame('multipart:', (string)$response->getBody());
 
         $response = $router->handle(HttpRequest::createRequestWithoutBody('POST', '/required-urlencoded'));
-        $this->assertEquals(400, $response->getStatusCode());
+        $this->assertSame(400, $response->getStatusCode());
 
         $response = $router->handle(HttpRequest::createRequestWithoutBody('POST', '/required-multipart'));
-        $this->assertEquals(400, $response->getStatusCode());
+        $this->assertSame(400, $response->getStatusCode());
 
         $response = $router->handle(HttpRequest::createRequestWithBody('POST', '/required-urlencoded', ['Content-Type' => ['application/x-www-form-urlencoded']], Stream::createStream('test=meow')));
-        $this->assertEquals(200, $response->getStatusCode());
-        $this->assertEquals('meow', (string)$response->getBody());
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertSame('meow', (string)$response->getBody());
 
         $response = $router->handle(HttpRequest::createRequestWithBody('POST', '/required-multipart', ['Content-Type' => ['multipart/form-data; boundary="--soap56789"']], Stream::createStream(implode("\r\n", [
             '----soap56789',
@@ -338,11 +341,11 @@ final class RouterTest extends TestCase {
             '----soap56789--',
             '',
         ]))));
-        $this->assertEquals(200, $response->getStatusCode());
-        $this->assertEquals('woof', (string)$response->getBody());
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertSame('woof', (string)$response->getBody());
 
         $response = $router->handle(HttpRequest::createRequestWithBody('POST', '/required-urlencoded', ['Content-Type' => ['multipart/form-data; boundary="--soap56789"']], Stream::createStream('test=meow')));
-        $this->assertEquals(400, $response->getStatusCode());
+        $this->assertSame(400, $response->getStatusCode());
 
         $response = $router->handle(HttpRequest::createRequestWithBody('POST', '/required-multipart', ['Content-Type' => ['application/x-www-form-urlencoded']], Stream::createStream(implode("\r\n", [
             '----soap56789',
@@ -352,7 +355,7 @@ final class RouterTest extends TestCase {
             '----soap56789--',
             '',
         ]))));
-        $this->assertEquals(400, $response->getStatusCode());
+        $this->assertSame(400, $response->getStatusCode());
 
         $response = $router->handle(HttpRequest::createRequestWithBody('POST', '/required-multipart', ['Content-Type' => ['multipart/form-data']], Stream::createStream(implode("\r\n", [
             '----aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
@@ -362,7 +365,7 @@ final class RouterTest extends TestCase {
             '----aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa--',
             '',
         ]))));
-        $this->assertEquals(400, $response->getStatusCode());
+        $this->assertSame(400, $response->getStatusCode());
     }
 
     public function testProcessors(): void {
@@ -424,15 +427,716 @@ final class RouterTest extends TestCase {
         ));
 
         $response = $router->handle(HttpRequest::createRequestWithBody('POST', '/test', [], Stream::createStream(base64_encode('mewow'))));
-        $this->assertEquals(200, $response->getStatusCode());
-        $this->assertEquals("{\n    \"data\": \"mewow\"\n}", (string)$response->getBody());
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertSame("{\n    \"data\": \"mewow\"\n}", (string)$response->getBody());
 
         $response = $router->handle(HttpRequest::createRequestWithBody('PUT', '/alternate', [], Stream::createStream(base64_encode('soap'))));
-        $this->assertEquals(200, $response->getStatusCode());
-        $this->assertEquals("{\n    \"path\": \"/alternate\",\n    \"data\": \"soap\"\n}", (string)$response->getBody());
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertSame("{\n    \"path\": \"/alternate\",\n    \"data\": \"soap\"\n}", (string)$response->getBody());
 
         $response = $router->handle(HttpRequest::createRequestWithoutBody('GET', '/filtered'));
-        $this->assertEquals(403, $response->getStatusCode());
-        $this->assertEquals('it can work like a filter too', (string)$response->getBody());
+        $this->assertSame(403, $response->getStatusCode());
+        $this->assertSame('it can work like a filter too', (string)$response->getBody());
+    }
+
+    public function testAccessControl(): void {
+        $router = new Router;
+
+        $router->register(new class implements RouteHandler {
+            use RouteHandlerCommon;
+
+
+            #[ExactRoute('GET', '/nothing')]
+            public function nothing(): void {}
+
+
+            #[AccessControl]
+            #[ExactRoute('GET', '/acld')]
+            public function getAccessControlDifferent(): void {}
+
+            #[ExactRoute('POST', '/acld')]
+            public function postAccessControlDifferent(): void {}
+
+
+            #[AccessControl]
+            #[ExactRoute('GET', '/copdodnop')]
+            public function getCredentialsOnPostDenyOnDeleteNothingOnPut(): void {}
+
+            #[AccessControl(credentials: true)]
+            #[ExactRoute('POST', '/copdodnop')]
+            public function postCredentialsOnPostDenyOnDeleteNothingOnPut(): void {}
+
+            #[AccessControl(allow: false)]
+            #[ExactRoute('DELETE', '/copdodnop')]
+            public function deleteCredentialsOnPostDenyOnDeleteNothingOnPut(): void {}
+
+            #[ExactRoute('PUT', '/copdodnop')]
+            public function putCredentialsOnPostDenyOnDeleteNothingOnPut(): void {}
+
+
+            #[AccessControl(allowMethods: ['HEAD'])]
+            #[ExactRoute('GET', '/methods/extra')]
+            public function getMethodsExtra(): void {}
+
+
+            #[AccessControl(allowHeaders: true)]
+            #[ExactRoute('GET', '/headers/allow-all')]
+            public function getHeadersAllowAll(): void {}
+
+            #[AccessControl(allowHeaders: ['Authorization', 'X-Content-Index', 'Content-Type'])]
+            #[ExactRoute('GET', '/headers/allow-specific')]
+            public function getHeadersAllowSpecific(): void {}
+
+
+            #[AccessControl]
+            #[ExactRoute('GET', '/headers/expose-safe')]
+            public function getHeadersExposeSafe(): void {}
+
+            #[AccessControl(exposeHeaders: true)]
+            #[ExactRoute('GET', '/headers/expose-all')]
+            public function getHeadersExposeAll(): void {}
+
+            #[AccessControl(exposeHeaders: ['X-EEPROM-Max-Size', 'Vary'])]
+            #[ExactRoute('GET', '/headers/expose-specific')]
+            public function getHeadersExposeSpecific(): void {}
+
+            // including a little curveball! i'm sure this won't lead to confusion
+            #[AccessControl(allow: true, credentials: true, allowMethods: ['POST', 'HEAD'], allowHeaders: ['Content-Length', 'X-Content-Index'], exposeHeaders: ['X-Satori-Time', 'X-Satori-Hash'])]
+            #[ExactRoute('GET', '/all-together-now')]
+            public function getAllTogetherNow(): void {}
+
+            #[ExactRoute('POST', '/all-together-now')]
+            public function postAllTogetherNow(): void {}
+        });
+
+        $routeWithNo = RouteInfo::exact(
+            'PUT', '/no-override',
+            #[AccessControl(allow: false)]
+            function(): void {}
+        );
+        $routeWithNo->accessControl = new AccessControl; // allow: true
+        $router->route($routeWithNo);
+
+        $response = $router->handle(HttpRequest::createRequestWithoutBody('OPTIONS', '/nothing'));
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertSame('0', $response->getHeaderLine('Content-Length'));
+        $this->assertSame('GET, HEAD, OPTIONS', $response->getHeaderLine('Allow'));
+
+        $response = $router->handle(HttpRequest::createRequestWithoutBody(
+            'OPTIONS', '/nothing',
+            [
+                'Origin' => ['https://railgun.sh'],
+                'Access-Control-Request-Method' => ['GET'],
+            ],
+        ));
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertSame('0', $response->getHeaderLine('Content-Length'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Origin'));
+        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
+        $this->assertFalse($response->hasHeader('Access-Control-Max-Age'));
+
+        $response = $router->handle(HttpRequest::createRequestWithoutBody(
+            'GET', '/nothing',
+            [
+                'Origin' => ['https://railgun.sh'],
+            ],
+        ));
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertFalse($response->hasHeader('Content-Length'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Origin'));
+        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
+        $this->assertFalse($response->hasHeader('Access-Control-Max-Age'));
+
+        $response = $router->handle(HttpRequest::createRequestWithoutBody(
+            'OPTIONS', '/no-override',
+            [
+                'Origin' => ['https://railgun.sh'],
+                'Access-Control-Request-Method' => ['PUT'],
+            ],
+        ));
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertSame('0', $response->getHeaderLine('Content-Length'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Origin'));
+        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
+        $this->assertFalse($response->hasHeader('Access-Control-Max-Age'));
+
+        $response = $router->handle(HttpRequest::createRequestWithoutBody(
+            'PUT', '/no-override',
+            [
+                'Origin' => ['https://railgun.sh'],
+            ],
+        ));
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertFalse($response->hasHeader('Content-Length'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Origin'));
+        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
+        $this->assertFalse($response->hasHeader('Access-Control-Max-Age'));
+
+        $response = $router->handle(HttpRequest::createRequestWithoutBody(
+            'OPTIONS', '/acld',
+            [
+                'Origin' => ['https://railgun.sh'],
+                'Access-Control-Request-Method' => ['GET'],
+            ],
+        ));
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertSame('0', $response->getHeaderLine('Content-Length'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
+        $this->assertSame('GET', $response->getHeaderLine('Access-Control-Allow-Methods'));
+        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
+        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
+        $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age'));
+
+        $response = $router->handle(HttpRequest::createRequestWithoutBody(
+            'GET', '/acld',
+            [
+                'Origin' => ['https://railgun.sh'],
+            ],
+        ));
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertFalse($response->hasHeader('Content-Length'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods'));
+        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
+        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
+        $this->assertFalse($response->hasHeader('Access-Control-Max-Age'));
+
+        $response = $router->handle(HttpRequest::createRequestWithoutBody(
+            'POST', '/acld',
+            [
+                'Origin' => ['https://railgun.sh'],
+            ],
+        ));
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertFalse($response->hasHeader('Content-Length'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Origin'));
+        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
+        $this->assertFalse($response->hasHeader('Access-Control-Max-Age'));
+
+        $response = $router->handle(HttpRequest::createRequestWithoutBody(
+            'OPTIONS', '/copdodnop',
+            [
+                'Origin' => ['https://railgun.sh'],
+                'Access-Control-Request-Method' => ['GET'],
+            ],
+        ));
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertSame('0', $response->getHeaderLine('Content-Length'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
+        $this->assertSame('GET, POST', $response->getHeaderLine('Access-Control-Allow-Methods'));
+        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
+        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
+        $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age'));
+
+        $response = $router->handle(HttpRequest::createRequestWithoutBody(
+            'GET', '/copdodnop',
+            [
+                'Origin' => ['https://railgun.sh'],
+            ],
+        ));
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertFalse($response->hasHeader('Content-Length'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods'));
+        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
+        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
+        $this->assertFalse($response->hasHeader('Access-Control-Max-Age'));
+
+        $response = $router->handle(HttpRequest::createRequestWithoutBody(
+            'OPTIONS', '/copdodnop',
+            [
+                'Origin' => ['https://railgun.sh'],
+                'Access-Control-Request-Method' => ['POST'],
+            ],
+        ));
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertSame('0', $response->getHeaderLine('Content-Length'));
+        $this->assertSame('true', $response->getHeaderLine('Access-Control-Allow-Credentials'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
+        $this->assertSame('GET, POST', $response->getHeaderLine('Access-Control-Allow-Methods'));
+        $this->assertSame('https://railgun.sh', $response->getHeaderLine('Access-Control-Allow-Origin'));
+        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
+        $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age'));
+
+        $response = $router->handle(HttpRequest::createRequestWithoutBody(
+            'POST', '/copdodnop',
+            [
+                'Origin' => ['https://railgun.sh'],
+            ],
+        ));
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertFalse($response->hasHeader('Content-Length'));
+        $this->assertSame('true', $response->getHeaderLine('Access-Control-Allow-Credentials'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods'));
+        $this->assertSame('https://railgun.sh', $response->getHeaderLine('Access-Control-Allow-Origin'));
+        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
+        $this->assertFalse($response->hasHeader('Access-Control-Max-Age'));
+
+        $response = $router->handle(HttpRequest::createRequestWithoutBody(
+            'OPTIONS', '/copdodnop',
+            [
+                'Origin' => ['https://railgun.sh'],
+                'Access-Control-Request-Method' => ['DELETE'],
+            ],
+        ));
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertSame('0', $response->getHeaderLine('Content-Length'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
+        $this->assertSame('GET, POST', $response->getHeaderLine('Access-Control-Allow-Methods'));
+        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
+        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
+        $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age'));
+
+        $response = $router->handle(HttpRequest::createRequestWithoutBody(
+            'DELETE', '/copdodnop',
+            [
+                'Origin' => ['https://railgun.sh'],
+            ],
+        ));
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertFalse($response->hasHeader('Content-Length'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Origin'));
+        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
+        $this->assertFalse($response->hasHeader('Access-Control-Max-Age'));
+
+        $response = $router->handle(HttpRequest::createRequestWithoutBody(
+            'OPTIONS', '/copdodnop',
+            [
+                'Origin' => ['https://railgun.sh'],
+                'Access-Control-Request-Method' => ['PUT'],
+            ],
+        ));
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertSame('0', $response->getHeaderLine('Content-Length'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
+        $this->assertSame('GET, POST', $response->getHeaderLine('Access-Control-Allow-Methods'));
+        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
+        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
+        $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age'));
+
+        $response = $router->handle(HttpRequest::createRequestWithoutBody(
+            'PUT', '/copdodnop',
+            [
+                'Origin' => ['https://railgun.sh'],
+            ],
+        ));
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertFalse($response->hasHeader('Content-Length'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Origin'));
+        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
+        $this->assertFalse($response->hasHeader('Access-Control-Max-Age'));
+
+        $response = $router->handle(HttpRequest::createRequestWithoutBody(
+            'OPTIONS', '/methods/extra',
+            [
+                'Origin' => ['https://railgun.sh'],
+                'Access-Control-Request-Method' => ['HEAD'],
+            ],
+        ));
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertSame('0', $response->getHeaderLine('Content-Length'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
+        $this->assertSame('GET, HEAD', $response->getHeaderLine('Access-Control-Allow-Methods'));
+        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
+        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
+        $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age'));
+
+        $response = $router->handle(HttpRequest::createRequestWithoutBody(
+            'GET', '/methods/extra',
+            [
+                'Origin' => ['https://railgun.sh'],
+            ],
+        ));
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertFalse($response->hasHeader('Content-Length'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods'));
+        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
+        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
+        $this->assertFalse($response->hasHeader('Access-Control-Max-Age'));
+
+        $response = $router->handle(HttpRequest::createRequestWithoutBody(
+            'HEAD', '/methods/extra',
+            [
+                'Origin' => ['https://railgun.sh'],
+            ],
+        ));
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertFalse($response->hasHeader('Content-Length'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods'));
+        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
+        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
+        $this->assertFalse($response->hasHeader('Access-Control-Max-Age'));
+
+        $response = $router->handle(HttpRequest::createRequestWithoutBody(
+            'OPTIONS', '/headers/allow-all',
+            [
+                'Origin' => ['https://railgun.sh'],
+                'Access-Control-Request-Method' => ['GET'],
+            ],
+        ));
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertSame('0', $response->getHeaderLine('Content-Length'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
+        $this->assertSame('GET', $response->getHeaderLine('Access-Control-Allow-Methods'));
+        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
+        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
+        $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age'));
+
+        $response = $router->handle(HttpRequest::createRequestWithoutBody(
+            'OPTIONS', '/headers/allow-all',
+            [
+                'Origin' => ['https://railgun.sh'],
+                'Access-Control-Request-Method' => ['GET'],
+                'Access-Control-Request-Headers' => ['X-Soap'],
+            ],
+        ));
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertSame('0', $response->getHeaderLine('Content-Length'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
+        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Headers'));
+        $this->assertSame('GET', $response->getHeaderLine('Access-Control-Allow-Methods'));
+        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
+        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
+        $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age'));
+
+        $response = $router->handle(HttpRequest::createRequestWithoutBody(
+            'GET', '/headers/allow-all',
+            [
+                'Origin' => ['https://railgun.sh'],
+            ],
+        ));
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertFalse($response->hasHeader('Content-Length'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods'));
+        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
+        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
+        $this->assertFalse($response->hasHeader('Access-Control-Max-Age'));
+
+        $response = $router->handle(HttpRequest::createRequestWithoutBody(
+            'OPTIONS', '/headers/allow-all',
+            [
+                'Origin' => ['https://railgun.sh'],
+                'Access-Control-Request-Method' => ['GET'],
+                'Access-Control-Request-Headers' => [''],
+            ],
+        ));
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertSame('0', $response->getHeaderLine('Content-Length'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
+        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Headers'));
+        $this->assertSame('GET', $response->getHeaderLine('Access-Control-Allow-Methods'));
+        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
+        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
+        $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age'));
+
+        $response = $router->handle(HttpRequest::createRequestWithoutBody(
+            'GET', '/headers/allow-all',
+            [
+                'Origin' => ['https://railgun.sh'],
+            ],
+        ));
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertFalse($response->hasHeader('Content-Length'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods'));
+        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
+        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
+        $this->assertFalse($response->hasHeader('Access-Control-Max-Age'));
+
+        $response = $router->handle(HttpRequest::createRequestWithoutBody(
+            'OPTIONS', '/headers/allow-specific',
+            [
+                'Origin' => ['https://railgun.sh'],
+                'Access-Control-Request-Method' => ['GET'],
+            ],
+        ));
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertSame('0', $response->getHeaderLine('Content-Length'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
+        $this->assertSame('GET', $response->getHeaderLine('Access-Control-Allow-Methods'));
+        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
+        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
+        $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age'));
+
+        $response = $router->handle(HttpRequest::createRequestWithoutBody(
+            'OPTIONS', '/headers/allow-specific',
+            [
+                'Origin' => ['https://railgun.sh'],
+                'Access-Control-Request-Method' => ['GET'],
+                'Access-Control-Request-Headers' => ['X-Content-Index', 'Authorization'],
+            ],
+        ));
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertSame('0', $response->getHeaderLine('Content-Length'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
+        $this->assertSame('Authorization, X-Content-Index', $response->getHeaderLine('Access-Control-Allow-Headers'));
+        $this->assertSame('GET', $response->getHeaderLine('Access-Control-Allow-Methods'));
+        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
+        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
+        $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age'));
+
+        $response = $router->handle(HttpRequest::createRequestWithoutBody(
+            'OPTIONS', '/headers/allow-specific',
+            [
+                'Origin' => ['https://railgun.sh'],
+                'Access-Control-Request-Method' => ['GET'],
+                'Access-Control-Request-Headers' => ['X-Beans'],
+            ],
+        ));
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertSame('0', $response->getHeaderLine('Content-Length'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Origin'));
+        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
+        $this->assertFalse($response->hasHeader('Access-Control-Max-Age'));
+
+        $response = $router->handle(HttpRequest::createRequestWithoutBody(
+            'GET', '/headers/allow-specific',
+            [
+                'Origin' => ['https://railgun.sh'],
+            ],
+        ));
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertFalse($response->hasHeader('Content-Length'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods'));
+        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
+        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
+        $this->assertFalse($response->hasHeader('Access-Control-Max-Age'));
+
+        $response = $router->handle(HttpRequest::createRequestWithoutBody(
+            'OPTIONS', '/headers/expose-safe',
+            [
+                'Origin' => ['https://railgun.sh'],
+                'Access-Control-Request-Method' => ['GET'],
+            ],
+        ));
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertSame('0', $response->getHeaderLine('Content-Length'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
+        $this->assertSame('GET', $response->getHeaderLine('Access-Control-Allow-Methods'));
+        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
+        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
+        $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age'));
+
+        $response = $router->handle(HttpRequest::createRequestWithoutBody(
+            'GET', '/headers/expose-safe',
+            [
+                'Origin' => ['https://railgun.sh'],
+            ],
+        ));
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertFalse($response->hasHeader('Content-Length'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods'));
+        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
+        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
+        $this->assertFalse($response->hasHeader('Access-Control-Max-Age'));
+
+        $response = $router->handle(HttpRequest::createRequestWithoutBody(
+            'OPTIONS', '/headers/expose-all',
+            [
+                'Origin' => ['https://railgun.sh'],
+                'Access-Control-Request-Method' => ['GET'],
+            ],
+        ));
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertSame('0', $response->getHeaderLine('Content-Length'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
+        $this->assertSame('GET', $response->getHeaderLine('Access-Control-Allow-Methods'));
+        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
+        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
+        $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age'));
+
+        $response = $router->handle(HttpRequest::createRequestWithoutBody(
+            'GET', '/headers/expose-all',
+            [
+                'Origin' => ['https://railgun.sh'],
+            ],
+        ));
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertFalse($response->hasHeader('Content-Length'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods'));
+        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
+        $this->assertTrue($response->hasHeader('Access-Control-Expose-Headers'));
+        $this->assertSame('*', $response->getHeaderLine('Access-Control-Expose-Headers'));
+        $this->assertFalse($response->hasHeader('Access-Control-Max-Age'));
+
+        $response = $router->handle(HttpRequest::createRequestWithoutBody(
+            'OPTIONS', '/headers/expose-specific',
+            [
+                'Origin' => ['https://railgun.sh'],
+                'Access-Control-Request-Method' => ['GET'],
+            ],
+        ));
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertSame('0', $response->getHeaderLine('Content-Length'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
+        $this->assertSame('GET', $response->getHeaderLine('Access-Control-Allow-Methods'));
+        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
+        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
+        $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age'));
+
+        $response = $router->handle(HttpRequest::createRequestWithoutBody(
+            'GET', '/headers/expose-specific',
+            [
+                'Origin' => ['https://railgun.sh'],
+            ],
+        ));
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertFalse($response->hasHeader('Content-Length'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods'));
+        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
+        $this->assertTrue($response->hasHeader('Access-Control-Expose-Headers'));
+        $this->assertSame('Vary, X-EEPROM-Max-Size', $response->getHeaderLine('Access-Control-Expose-Headers'));
+        $this->assertFalse($response->hasHeader('Access-Control-Max-Age'));
+
+        $response = $router->handle(HttpRequest::createRequestWithoutBody(
+            'OPTIONS', '/all-together-now',
+            [
+                'Origin' => ['https://flash.moe:8443'],
+                'Access-Control-Request-Method' => ['GET'],
+            ],
+        ));
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertSame('0', $response->getHeaderLine('Content-Length'));
+        $this->assertSame('true', $response->getHeaderLine('Access-Control-Allow-Credentials'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
+        $this->assertSame('GET, HEAD, POST', $response->getHeaderLine('Access-Control-Allow-Methods'));
+        $this->assertSame('https://flash.moe:8443', $response->getHeaderLine('Access-Control-Allow-Origin'));
+        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
+        $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age'));
+
+        $response = $router->handle(HttpRequest::createRequestWithoutBody(
+            'OPTIONS', '/all-together-now',
+            [
+                'Origin' => ['https://flash.moe:8443'],
+                'Access-Control-Request-Method' => ['GET'],
+                'Access-Control-Request-Headers' => [''],
+            ],
+        ));
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertSame('0', $response->getHeaderLine('Content-Length'));
+        $this->assertSame('true', $response->getHeaderLine('Access-Control-Allow-Credentials'));
+        $this->assertTrue($response->hasHeader('Access-Control-Allow-Headers'));
+        $this->assertSame('', $response->getHeaderLine('Access-Control-Allow-Headers'));
+        $this->assertSame('GET, HEAD, POST', $response->getHeaderLine('Access-Control-Allow-Methods'));
+        $this->assertSame('https://flash.moe:8443', $response->getHeaderLine('Access-Control-Allow-Origin'));
+        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
+        $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age'));
+
+        $response = $router->handle(HttpRequest::createRequestWithoutBody(
+            'OPTIONS', '/all-together-now',
+            [
+                'Origin' => ['https://flash.moe:8443'],
+                'Access-Control-Request-Method' => ['GET'],
+                'Access-Control-Request-Headers' => ['x-content-index'],
+            ],
+        ));
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertSame('0', $response->getHeaderLine('Content-Length'));
+        $this->assertSame('true', $response->getHeaderLine('Access-Control-Allow-Credentials'));
+        $this->assertSame('x-content-index', $response->getHeaderLine('Access-Control-Allow-Headers'));
+        $this->assertSame('GET, HEAD, POST', $response->getHeaderLine('Access-Control-Allow-Methods'));
+        $this->assertSame('https://flash.moe:8443', $response->getHeaderLine('Access-Control-Allow-Origin'));
+        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
+        $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age'));
+
+        $response = $router->handle(HttpRequest::createRequestWithoutBody(
+            'GET', '/all-together-now',
+            [
+                'Origin' => ['https://flash.moe:8443'],
+            ],
+        ));
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertFalse($response->hasHeader('Content-Length'));
+        $this->assertSame('true', $response->getHeaderLine('Access-Control-Allow-Credentials'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods'));
+        $this->assertSame('https://flash.moe:8443', $response->getHeaderLine('Access-Control-Allow-Origin'));
+        $this->assertSame('X-Satori-Hash, X-Satori-Time', $response->getHeaderLine('Access-Control-Expose-Headers'));
+        $this->assertFalse($response->hasHeader('Access-Control-Max-Age'));
+
+        // although routes can declare other methods than their own as CORS supported,
+        // allow credentials and headers ONLY apply to their own method
+        $response = $router->handle(HttpRequest::createRequestWithoutBody(
+            'OPTIONS', '/all-together-now',
+            [
+                'Origin' => ['https://flash.moe:8443'],
+                'Access-Control-Request-Method' => ['POST'],
+            ],
+        ));
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertSame('0', $response->getHeaderLine('Content-Length'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
+        $this->assertSame('GET, HEAD, POST', $response->getHeaderLine('Access-Control-Allow-Methods'));
+        $this->assertSame('*', $response->getHeaderLine('Access-Control-Allow-Origin'));
+        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
+        $this->assertSame('300', $response->getHeaderLine('Access-Control-Max-Age'));
+
+        $response = $router->handle(HttpRequest::createRequestWithoutBody(
+            'POST', '/all-together-now',
+            [
+                'Origin' => ['https://flash.moe:8443'],
+            ],
+        ));
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertFalse($response->hasHeader('Content-Length'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Credentials'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Headers'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods'));
+        $this->assertFalse($response->hasHeader('Access-Control-Allow-Origin'));
+        $this->assertFalse($response->hasHeader('Access-Control-Expose-Headers'));
+        $this->assertFalse($response->hasHeader('Access-Control-Max-Age'));
     }
 }