diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..9b75b70ff39d76443d9186dba5050b6956e5ab48
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+vendor/
+*.cache
+composer.lock
+reports/
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
new file mode 100644
index 0000000000000000000000000000000000000000..8307adff0fc180c83ec8786bfdd9f5a4a96b84d0
--- /dev/null
+++ b/.gitlab-ci.yml
@@ -0,0 +1,44 @@
+workflow:
+  rules:
+    - if: $CI_MERGE_REQUEST_IID
+    - if: $CI_COMMIT_TAG
+    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
+
+variables:
+  PHP_IMAGE: php:7.2
+
+cache:
+  key: ${CI_COMMIT_REF_SLUG}
+  paths:
+    - vendor/
+
+before_script:
+  # Install composer
+  - curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
+  # Install libraries needed by composer
+  - apt-get update && apt-get install -y git && apt-get install -y unzip
+  # Intall Xdebug
+  - pecl install xdebug-2.8.1 && docker-php-ext-enable xdebug
+
+stages:
+  - build
+  - test
+
+build:
+  stage: build
+  image: $PHP_IMAGE
+  script:
+    - composer install
+
+test:
+  stage: test
+  image: $PHP_IMAGE
+  script:
+    - composer run test:ci
+  artifacts:
+    when: always
+    reports:
+      junit:
+        - reports/phpunit.xml
+        - reports/phpcs.xml
+        - reports/psalm.xml
diff --git a/README.md b/README.md
index 73f076b8efcf957453241e6e00c2e8fac19eb02f..bd639fed75a92f66c327e07708217935b0d0f373 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,63 @@
-# cors-middleware
+# CORS Middleware
 
+A simple [PSR-15](https://www.php-fig.org/psr/psr-15/) middleware for handling CORS.
+
+## Installation
+
+Install using Composer:
+
+```bash
+composer require glance-project/cors-middleware
+```
+
+## Usage
+
+This middleware can be used with any framework compatible with PSR-7 and PSR-15.
+On the following examples, Slim will be used.
+
+### Basic usage
+
+On most of the cases, the middleware can be used out of the box.
+
+```php
+<?php
+
+use Glance\CorsMiddleware\CorsMiddleware;
+
+$app = new \Slim\App();
+
+$corsMiddleware = CorsMiddleware::create();
+
+$app->add($corsMiddleware);
+```
+
+It will add the following headers to your response:
+
+```
+Access-Control-Allow-Origin: *
+Access-Control-Allow-Headers: Content-Type, Authorization
+Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH
+```
+
+If the request has the method `OPTIONS` and empty response will be returned.
+
+### Custom origins
+
+```php
+$corsMiddleware = CorsMiddleware::create()
+    ->withAllowedOrigins(["localhost"]);
+```
+
+### Custom headers
+
+```php
+$corsMiddleware = CorsMiddleware::create()
+    ->withAllowedHeaders(["Content-Type", "Api-Key"]);
+```
+
+### Custom methods
+
+```php
+$corsMiddleware = CorsMiddleware::create()
+    ->withAllowedMethods(["GET", "POST"]);
+```
\ No newline at end of file
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000000000000000000000000000000000000..5e2b7b626e59e45604cae03d984ca74e8fcf148c
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,56 @@
+{
+    "name": "glance-project/cors-middleware",
+    "description": "A simple PSR middleware to handle CORS",
+    "type": "library",
+    "license": "proprietary",
+    "config": {
+        "platform-check": false,
+        "sort-packages": true
+    },
+    "authors": [
+        {
+            "name": "Mario Gunter Simao",
+            "email": "mario.simao@cern.ch"
+        }
+    ],
+    "scripts": {
+        "test": [
+            "@test:standard",
+            "@test:types",
+            "@test:unit"
+        ],
+        "test:ci": [
+            "@test:ci:unit",
+            "@test:ci:standard",
+            "@test:types"
+        ],
+        "test:types": "psalm --show-info=true --no-diff",
+        "test:standard": "phpcs src/ tests/ --standard=psr12",
+        "test:unit": "phpunit tests --configuration phpunit.xml",
+        "test:ci:standard": "phpcs src/ tests/ --standard=psr12 --report=junit > reports/phpcs.xml",
+        "test:ci:unit": "phpunit tests --configuration phpunit.xml --coverage-text --colors=never --log-junit reports/phpunit.xml"
+    },
+    "autoload": {
+        "psr-4": {
+            "GlanceProject\\CorsMiddleware\\": "src/"
+        }
+    },
+    "autoload-dev": {
+        "psr-4": {
+            "GlanceProject\\CorsMiddleware\\Tests\\": "tests/"
+        }
+    },
+    "require": {
+        "php": "^7.2",
+        "psr/http-server-middleware": "^1.0",
+        "psr/http-factory": "^1.0",
+        "php-http/discovery": "^1.14"
+    },
+    "require-dev": {
+        "dq5studios/psalm-junit": "^2.0",
+        "nyholm/psr7": "^1.5",
+        "phpunit/phpunit": "^8.5",
+        "squizlabs/php_codesniffer": "^3.5",
+        "vimeo/psalm": "^4.1"
+    }
+}
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000000000000000000000000000000000000..b609246702cf96c84867bfc0d2066fecf3d45f79
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<phpunit
+    colors="true"
+>
+
+    <testsuites>
+        <testsuite name="Unit Tests">
+            <directory>tests/Unit</directory>
+        </testsuite>
+    </testsuites>
+
+    <filter>
+        <whitelist>
+            <directory suffix=".php">src/</directory>
+        </whitelist>
+    </filter>
+
+</phpunit>
diff --git a/psalm.xml b/psalm.xml
new file mode 100644
index 0000000000000000000000000000000000000000..540eb7d7b06671626b545529488eb73b6d6a1136
--- /dev/null
+++ b/psalm.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0"?>
+<psalm
+    errorLevel="1"
+    resolveFromConfigFile="true"
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    xmlns="https://getpsalm.org/schema/config"
+    xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd"
+>
+    <projectFiles>
+        <directory name="src"/>
+        <ignoreFiles>
+            <directory name="vendor"/>
+        </ignoreFiles>
+    </projectFiles>
+    <plugins>
+        <pluginClass class="DQ5Studios\PsalmJunit\Plugin">
+            <filepath>reports/psalm.xml</filepath>
+        </pluginClass>
+    </plugins>
+</psalm>
diff --git a/src/CorsMiddleware.php b/src/CorsMiddleware.php
new file mode 100644
index 0000000000000000000000000000000000000000..b5a0f1bb2e1c5b0e5ebd223548046f95fb4285b3
--- /dev/null
+++ b/src/CorsMiddleware.php
@@ -0,0 +1,146 @@
+<?php
+
+namespace GlanceProject\CorsMiddleware;
+
+use Http\Discovery\Psr17FactoryDiscovery;
+use Psr\Http\Message\ResponseFactoryInterface;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Server\MiddlewareInterface;
+use Psr\Http\Server\RequestHandlerInterface;
+
+class CorsMiddleware implements MiddlewareInterface
+{
+    private $responseFactory;
+    private $allowedOrigins;
+    private $allowedHeaders;
+    private $allowedMethods;
+    private $bypassMethods;
+
+    /**
+     * @param ResponseFactoryInterface $responseFactory
+     * @param string[] $allowedOrigins
+     * @param string[] $allowedHeaders
+     * @param string[] $allowedMethods
+     * @param string[] $bypassMethods
+     */
+    private function __construct(
+        ResponseFactoryInterface $responseFactory,
+        array $allowedOrigins,
+        array $allowedHeaders,
+        array $allowedMethods,
+        array $bypassMethods
+    ) {
+        $this->responseFactory = $responseFactory;
+        $this->allowedOrigins = $allowedOrigins;
+        $this->allowedHeaders = $allowedHeaders;
+        $this->allowedMethods = $allowedMethods;
+        $this->bypassMethods = $bypassMethods;
+    }
+
+    public static function create(): self
+    {
+        return new self(
+            Psr17FactoryDiscovery::findResponseFactory(),
+            ["*"],
+            ["Content-Type", "Authorization"],
+            ["GET", "POST", "PUT", "DELETE", "PATCH"],
+            ["OPTIONS"]
+        );
+    }
+
+    /**
+     * Replace allowed origins
+     *
+     * Default value: "*"
+     *
+     * @param string[] $allowedOrigins
+     *
+     * @return self
+     */
+    public function withAllowedOrigins(array $allowedOrigins): self
+    {
+        return new self(
+            $this->responseFactory,
+            $allowedOrigins,
+            $this->allowedHeaders,
+            $this->allowedMethods,
+            $this->bypassMethods
+        );
+    }
+
+    /**
+     * Replace allowed headers
+     *
+     * Default value: "Content-Type, Authorization"
+     *
+     * @param string[] $allowedHeaders
+     *
+     * @return self
+     */
+    public function withAllowedHeaders(array $allowedHeaders): self
+    {
+        return new self(
+            $this->responseFactory,
+            $this->allowedOrigins,
+            $allowedHeaders,
+            $this->allowedMethods,
+            $this->bypassMethods
+        );
+    }
+
+    /**
+     * Replace allowed methods
+     *
+     * Default value: "GET, POST, PUT, DELTE, PATCH"
+     *
+     * @param string[] $allowedMethods
+     *
+     * @return self
+     */
+    public function withAllowedMethods(array $allowedMethods): self
+    {
+        return new self(
+            $this->responseFactory,
+            $this->allowedOrigins,
+            $this->allowedHeaders,
+            $allowedMethods,
+            $this->bypassMethods
+        );
+    }
+
+    /**
+     * Replace bypass methods.
+     *
+     * Default value: "OPTIONS"
+     *
+     * @param string[] $bypassMethods
+     *
+     * @return self
+     */
+    public function withBypassMethods(array $bypassMethods): self
+    {
+        return new self(
+            $this->responseFactory,
+            $this->allowedOrigins,
+            $this->allowedHeaders,
+            $this->allowedMethods,
+            $bypassMethods
+        );
+    }
+
+    public function process(Request $request, RequestHandlerInterface $handler): Response
+    {
+        $response = $this->responseFactory->createResponse();
+
+        $bypass = in_array($request->getMethod(), $this->bypassMethods);
+        if (!$bypass) {
+            $response = $handler->handle($request);
+        }
+
+        return $response
+            ->withHeader("Access-Control-Allow-Origin", implode(", ", $this->allowedOrigins))
+            ->withHeader("Access-Control-Allow-Headers", implode(", ", $this->allowedHeaders))
+            ->withHeader("Access-Control-Allow-Methods", implode(", ", $this->allowedMethods));
+    }
+}
diff --git a/tests/Unit/CorsMiddlewareTest.php b/tests/Unit/CorsMiddlewareTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..98ebd84e04694f60ee80a05b8c420b329879114e
--- /dev/null
+++ b/tests/Unit/CorsMiddlewareTest.php
@@ -0,0 +1,123 @@
+<?php
+
+namespace GlanceProject\CorsMiddleware\Tests\Unit;
+
+use GlanceProject\CorsMiddleware\CorsMiddleware;
+use Nyholm\Psr7\Response;
+use Nyholm\Psr7\ServerRequest;
+use PHPUnit\Framework\TestCase;
+use Psr\Http\Server\RequestHandlerInterface;
+
+class CorsMiddlewareTest extends TestCase
+{
+    public function testMiddleware(): void
+    {
+        $request = new ServerRequest("GET", "http://glance.cern.ch/api/members");
+
+        $handler = $this->createMock(RequestHandlerInterface::class);
+        $handler->method("handle")->willReturn(new Response());
+
+        $middleware = CorsMiddleware::create();
+
+        $response = $middleware->process($request, $handler);
+
+        $allowedOrigins = $response->getHeader("Access-Control-Allow-Origin");
+        $allowedHeaders = $response->getHeader("Access-Control-Allow-Headers");
+        $allowedMethods = $response->getHeader("Access-Control-Allow-Methods");
+
+        $this->assertSame([ "*" ], $allowedOrigins);
+        $this->assertSame([ "Content-Type, Authorization" ], $allowedHeaders);
+        $this->assertSame([ "GET, POST, PUT, DELETE, PATCH" ], $allowedMethods);
+    }
+
+    public function testOptionsRequest(): void
+    {
+        $request = new ServerRequest("OPTIONS", "http://glance.cern.ch/api/members");
+
+        $handler = $this->createMock(RequestHandlerInterface::class);
+        $handler->method("handle")->willReturn(new Response(400));
+
+        $middleware = CorsMiddleware::create();
+
+        $response = $middleware->process($request, $handler);
+
+        $allowedOrigins = $response->getHeader("Access-Control-Allow-Origin");
+        $allowedHeaders = $response->getHeader("Access-Control-Allow-Headers");
+        $allowedMethods = $response->getHeader("Access-Control-Allow-Methods");
+
+        $this->assertSame(200, $response->getStatusCode());
+        $this->assertSame([ "*" ], $allowedOrigins);
+        $this->assertSame([ "Content-Type, Authorization" ], $allowedHeaders);
+        $this->assertSame([ "GET, POST, PUT, DELETE, PATCH" ], $allowedMethods);
+    }
+
+    public function testCustomAllowedOrigins(): void
+    {
+        $request = new ServerRequest("GET", "http://glance.cern.ch/api/members");
+
+        $handler = $this->createMock(RequestHandlerInterface::class);
+        $handler->method("handle")->willReturn(new Response(200));
+
+        $customOrigins = [ "http://localhost:8080", "http://glance.cern.ch/client" ];
+        $middleware = CorsMiddleware::create()
+                        ->withAllowedOrigins($customOrigins);
+
+        $response = $middleware->process($request, $handler);
+
+        $allowedOrigins = $response->getHeader("Access-Control-Allow-Origin");
+
+        $this->assertSame([ "http://localhost:8080, http://glance.cern.ch/client" ], $allowedOrigins);
+    }
+
+    public function testCustomAllowedHeaders(): void
+    {
+        $request = new ServerRequest("GET", "http://glance.cern.ch/api/members");
+
+        $handler = $this->createMock(RequestHandlerInterface::class);
+        $handler->method("handle")->willReturn(new Response(200));
+
+        $customHeaders = [ "Content-Type", "Api-Key" ];
+        $middleware = CorsMiddleware::create()
+                        ->withAllowedHeaders($customHeaders);
+
+        $response = $middleware->process($request, $handler);
+
+        $allowedHeaders = $response->getHeader("Access-Control-Allow-Headers");
+
+        $this->assertSame([ "Content-Type, Api-Key" ], $allowedHeaders);
+    }
+
+    public function testCustomAllowedMethods(): void
+    {
+        $request = new ServerRequest("GET", "http://glance.cern.ch/api/members");
+
+        $handler = $this->createMock(RequestHandlerInterface::class);
+        $handler->method("handle")->willReturn(new Response(200));
+
+        $customMethods = [ "GET", "POST" ];
+        $middleware = CorsMiddleware::create()
+                        ->withAllowedMethods($customMethods);
+
+        $response = $middleware->process($request, $handler);
+
+        $allowedMethods = $response->getHeader("Access-Control-Allow-Methods");
+
+        $this->assertSame([ "GET, POST" ], $allowedMethods);
+    }
+
+    public function testCustomBypassMethods(): void
+    {
+        $request = new ServerRequest("MYOPTIONS", "http://glance.cern.ch/api/members");
+
+        $handler = $this->createMock(RequestHandlerInterface::class);
+        $handler->method("handle")->willReturn(new Response(400));
+
+        $customMethods = [ "MYOPTIONS" ];
+        $middleware = CorsMiddleware::create()
+                        ->withBypassMethods($customMethods);
+
+        $response = $middleware->process($request, $handler);
+
+        $this->assertSame(200, $response->getStatusCode());
+    }
+}