diff --git a/classes/IdTokenBuilder.php b/classes/IdTokenBuilder.php
index a198208ae65a44d5abd44645b20340b909f91ee0..e5eb200b5b58f28db6edf9df1967b12f25876318 100644
--- a/classes/IdTokenBuilder.php
+++ b/classes/IdTokenBuilder.php
@@ -12,7 +12,7 @@ class IdTokenBuilder {
         $this->locator = $locator;
     }
 
-    public function getIdToken(AccessToken $accessToken, $nonce = null): string {
+    public function getIdToken(AccessToken $accessToken, string $nonce = null): string {
 
         $keyPair = $this->locator->getJWKSDAO()->getNewestKeyPair();
 
@@ -21,15 +21,15 @@ class IdTokenBuilder {
         return JWT::encode($payload, $keyPair->privateKey, $keyPair->alg, $keyPair->keyId);
     }
 
-    private function createPayloadArray(AccessToken $accessToken, $nonce = null) {
+    private function createPayloadArray(AccessToken $accessToken, string $nonce = null) {
 
         $user = $this->locator->getUserDAO()->findUserById($accessToken->userId);
 
         $payloadArr = array(
             'iss' => $this->locator->config->jwtIssuer,
             'sub' => $user->id,
-            'iat' => time(),
-            'exp' => time() + 3600,
+            'iat' => $accessToken->creationTime,
+            'exp' => $accessToken->expirationTime,
             'name' => $user->getCompleteName(),
             'aud' => $accessToken->clientId
         );
diff --git a/classes/Locator.php b/classes/Locator.php
index 826afd3694968d5b21776ed023a7c92d8d329808..f2ea9da0c6346e197508c0901f0dd017e43f4916 100644
--- a/classes/Locator.php
+++ b/classes/Locator.php
@@ -72,6 +72,16 @@ class Locator {
         }
     }
 
+    public function getRefreshTokenDAO(): RefreshTokenDAO {
+        $databaseConfig = $this->config->databaseConfig;
+        switch ($databaseConfig->dbtype) {
+            case 'MySQL':
+                return new MySQLRefreshTokenDAO($this);
+            default:
+                throw new \Exception($databaseConfig->dbtype . ' not supported yet');
+        }
+    }
+
     public function getCallbackHandler(): CallbackHandler {
         return new CallbackHandler($this);
     }
diff --git a/classes/OAuth2RequestHandler.php b/classes/OAuth2RequestHandler.php
index 44175c438bf76b0347beb58d1773b1817348541f..895752532af055fb221cd85289ef9edd7684fcca 100644
--- a/classes/OAuth2RequestHandler.php
+++ b/classes/OAuth2RequestHandler.php
@@ -90,7 +90,16 @@ class OAuth2RequestHandler {
 
     public function handleAccessTokenRequest($params): array {
 
-        $this->validateAccessTokenRequest($params);
+        if ($params['code'] === null) {
+            throw new BadRequestException("code id is required");
+        }
+
+        if ($params['redirect_uri'] === null) {
+            throw new BadRequestException("Redirect URI is required");
+        }
+
+        // Note: theorically the standard wants also the client_id here,
+        // however some clients don't send it
 
         $accessToken = $this->locator->getAccessTokenDAO()->retrieveAccessTokenFromCode($params['code']);
 
@@ -102,10 +111,73 @@ class OAuth2RequestHandler {
             throw new BadRequestException("Invalid redirect URI: " . $params['redirect_uri']);
         }
 
+        return $this->getAccessTokenResponse($accessToken);
+    }
+
+    public function handleRefreshTokenRequest($params): array {
+
+        if ($params['refresh_token'] === null) {
+            throw new BadRequestException("refresh_token is required");
+        }
+
+        $refreshToken = $this->locator->getRefreshTokenDAO()->getRefreshToken($params['refresh_token']);
+
+        if ($refreshToken === null || $refreshToken->isExpired()) {
+            throw new UnauthorizedException("Invalid refresh token");
+        }
+
+        $scope = $this->getScope($params, $refreshToken);
+
+        // Generating a new access token
+        $accessToken = new AccessToken();
+        $accessToken->token = base64_encode(bin2hex(openssl_random_pseudo_bytes(128)));
+        $accessToken->clientId = $refreshToken->clientId;
+        $accessToken->userId = $refreshToken->userId;
+        $accessToken->scope = $scope;
+
+        $accessToken = $this->locator->getAccessTokenDAO()->createAccessToken($accessToken);
+
+        return $this->getAccessTokenResponse($accessToken);
+    }
+
+    /**
+     * We can request a new access token with a scope that is a subset (or the
+     * same set) of the scope defined for the refresh token.
+     */
+    private function getScope(array $params, RefreshToken $refreshToken): ?array {
+
+        $scope = $refreshToken->scope;
+
+        if ($params['scope'] !== null) {
+
+            $newScopeValues = explode(' ', $params['scope']);
+
+            foreach ($newScopeValues as $newScopeValue) {
+                $found = false;
+                foreach ($scope as $oldScopeValue) {
+                    if ($oldScopeValue === $newScopeValue) {
+                        $found = true;
+                        break;
+                    }
+                }
+                if (!$found) {
+                    throw new BadRequestException("Scope " . $newScopeValue . " was not defined for the given refresh token");
+                }
+            }
+
+            $scope = $newScopeValues;
+        }
+
+        return $scope;
+    }
+
+    private function getAccessTokenResponse(AccessToken $accessToken) {
+
         $result = [];
         $result['access_token'] = $accessToken->token;
         $result['token_type'] = 'Bearer';
         $result['expires_in'] = $accessToken->expirationTime - time();
+        $result['refresh_token'] = $this->getNewRefreshToken($accessToken);
 
         if ($accessToken->scope !== null && in_array('openid', $accessToken->scope)) {
             $result['id_token'] = $this->locator->getIdTokenBuilder()->getIdToken($accessToken);
@@ -114,24 +186,17 @@ class OAuth2RequestHandler {
         return $result;
     }
 
-    private function validateAccessTokenRequest($params) {
+    private function getNewRefreshToken(AccessToken $accessToken): string {
 
-        if ($params['grant_type'] === null) {
-            throw new BadRequestException("grant_type is required");
-        } else if ($params['grant_type'] !== 'authorization_code') {
-            throw new BadRequestException("grant_type must be authorization_code");
-        }
-
-        if ($params['code'] === null) {
-            throw new BadRequestException("code id is required");
-        }
+        $refreshToken = new RefreshToken();
+        $refreshToken->token = base64_encode(bin2hex(openssl_random_pseudo_bytes(128)));
+        $refreshToken->clientId = $accessToken->clientId;
+        $refreshToken->userId = $accessToken->userId;
+        $refreshToken->scope = $accessToken->scope;
 
-        if ($params['redirect_uri'] === null) {
-            throw new BadRequestException("Redirect URI is required");
-        }
+        $this->locator->getRefreshTokenDAO()->createRefreshToken($refreshToken);
 
-        // Note: theorically the standard wants also the client_id here,
-        // however some clients don't send it
+        return $refreshToken->token;
     }
 
     public function handleCheckTokenRequest($token): array {
@@ -171,7 +236,7 @@ class OAuth2RequestHandler {
         $accessToken = $this->locator->getAccessTokenDAO()->getAccessToken($bearer_token);
         if ($accessToken === null) {
             $this->attemptJWTTokenValidation($bearer_token);
-        } else if ($accessToken->expired) {
+        } else if ($accessToken->isExpired()) {
             throw new UnauthorizedException("Access token is expired");
         }
     }
diff --git a/classes/datalayer/AccessTokenDAO.php b/classes/datalayer/AccessTokenDAO.php
index 9525d1c77a41984120fb2632fbe53139f8aa16a3..34a25b0a040b1f7ae6c72c395d8c10b9a587b652 100644
--- a/classes/datalayer/AccessTokenDAO.php
+++ b/classes/datalayer/AccessTokenDAO.php
@@ -15,11 +15,5 @@ interface AccessTokenDAO {
 
     function getAccessToken(string $token): ?AccessToken;
 
-    /**
-     * Delete an access token from the database. This happens when the caller
-     * application has received the token and used it for retrieving user
-     * information from the token using the RAP REST web service.
-     * @param type $token login token
-     */
     function deleteAccessToken(string $token): void;
 }
diff --git a/classes/datalayer/RefreshTokenDAO.php b/classes/datalayer/RefreshTokenDAO.php
new file mode 100644
index 0000000000000000000000000000000000000000..e8196cbe7d583417945ef99b1a4c49808995382c
--- /dev/null
+++ b/classes/datalayer/RefreshTokenDAO.php
@@ -0,0 +1,12 @@
+<?php
+
+namespace RAP;
+
+interface RefreshTokenDAO {
+
+    function createRefreshToken(RefreshToken $refreshToken): RefreshToken;
+
+    function getRefreshToken(string $token): ?RefreshToken;
+
+    function deleteRefreshToken(string $token): void;
+}
diff --git a/classes/datalayer/mysql/MySQLAccessTokenDAO.php b/classes/datalayer/mysql/MySQLAccessTokenDAO.php
index 28c4fad2bdcaac49d065472a3db20848cd05eba2..446be56f0d31c8f8872d82868c85add01e333c17 100644
--- a/classes/datalayer/mysql/MySQLAccessTokenDAO.php
+++ b/classes/datalayer/mysql/MySQLAccessTokenDAO.php
@@ -11,9 +11,8 @@ class MySQLAccessTokenDAO extends BaseMySQLDAO implements AccessTokenDAO {
     public function createAccessToken(AccessToken $accessToken): AccessToken {
 
         $dbh = $this->getDBHandler();
-        $stmt = $dbh->prepare("INSERT INTO access_token (token, code, user_id, redirect_uri, client_id, scope, expiration_time)"
-                . " VALUES(:token, :code, :user_id, :redirect_uri, :client_id, :scope, "
-                . " UNIX_TIMESTAMP(TIMESTAMPADD(HOUR, 1, CURRENT_TIMESTAMP)))");
+        $stmt = $dbh->prepare("INSERT INTO access_token (token, code, user_id, redirect_uri, client_id, scope, creation_time, expiration_time)"
+                . " VALUES(:token, :code, :user_id, :redirect_uri, :client_id, :scope, :creation_time, :expiration_time)");
 
         $scope = null;
         if ($accessToken->scope !== null) {
@@ -26,7 +25,9 @@ class MySQLAccessTokenDAO extends BaseMySQLDAO implements AccessTokenDAO {
             ':user_id' => $accessToken->userId,
             ':redirect_uri' => $accessToken->redirectUri,
             ':client_id' => $accessToken->clientId,
-            ':scope' => $scope
+            ':scope' => $scope,
+            ':creation_time' => $accessToken->creationTime,
+            ':expiration_time' => $accessToken->expirationTime
         );
 
         if ($stmt->execute($params)) {
@@ -42,8 +43,7 @@ class MySQLAccessTokenDAO extends BaseMySQLDAO implements AccessTokenDAO {
         $dbh = $this->getDBHandler();
 
         // Access token can be retrieved from code in 1 minute from the creation
-        $stmt = $dbh->prepare("SELECT token, code, user_id, redirect_uri, client_id, creation_time, expiration_time, scope,"
-                . " (expiration_time < UNIX_TIMESTAMP()) AS expired "
+        $stmt = $dbh->prepare("SELECT token, code, user_id, redirect_uri, client_id, creation_time, expiration_time, scope "
                 . " FROM access_token WHERE code = :code AND UNIX_TIMESTAMP() < (creation_time + 60)");
         $stmt->bindParam(':code', $code);
 
@@ -61,8 +61,7 @@ class MySQLAccessTokenDAO extends BaseMySQLDAO implements AccessTokenDAO {
 
         $dbh = $this->getDBHandler();
 
-        $stmt = $dbh->prepare("SELECT token, code, user_id, redirect_uri, client_id, creation_time, expiration_time, scope,"
-                . " (expiration_time < UNIX_TIMESTAMP()) AS expired "
+        $stmt = $dbh->prepare("SELECT token, code, user_id, redirect_uri, client_id, creation_time, expiration_time, scope "
                 . " FROM access_token WHERE token = :token");
         $stmt->bindParam(':token', $token);
 
@@ -86,7 +85,6 @@ class MySQLAccessTokenDAO extends BaseMySQLDAO implements AccessTokenDAO {
         $token->clientId = $row['client_id'];
         $token->creationTime = $row['creation_time'];
         $token->expirationTime = $row['expiration_time'];
-        $token->expired = $row['expired'] === "1";
 
         $scope = null;
         if (isset($row['scope'])) {
diff --git a/classes/datalayer/mysql/MySQLRefreshTokenDAO.php b/classes/datalayer/mysql/MySQLRefreshTokenDAO.php
new file mode 100644
index 0000000000000000000000000000000000000000..f316aad55fcb14f6bdaf9f77292fdb592fff0a41
--- /dev/null
+++ b/classes/datalayer/mysql/MySQLRefreshTokenDAO.php
@@ -0,0 +1,82 @@
+<?php
+
+namespace RAP;
+
+class MySQLRefreshTokenDAO extends BaseMySQLDAO implements RefreshTokenDAO {
+
+    public function __construct(Locator $locator) {
+        parent::__construct($locator);
+    }
+
+    function createRefreshToken(RefreshToken $refreshToken): RefreshToken {
+
+        $dbh = $this->getDBHandler();
+        $stmt = $dbh->prepare("INSERT INTO refresh_token (token, user_id, client_id, scope, creation_time, expiration_time)"
+                . " VALUES(:token, :user_id, :client_id, :scope, :creation_time, :expiration_time)");
+
+        $scope = null;
+        if ($refreshToken->scope !== null) {
+            $scope = join(' ', $refreshToken->scope);
+        }
+
+        $params = array(
+            ':token' => $refreshToken->token,
+            ':user_id' => $refreshToken->userId,
+            ':client_id' => $refreshToken->clientId,
+            ':scope' => $scope,
+            ':creation_time' => $refreshToken->creationTime,
+            ':expiration_time' => $refreshToken->expirationTime
+        );
+
+        if ($stmt->execute($params)) {
+            return $refreshToken;
+        } else {
+            error_log($stmt->errorInfo()[2]);
+            throw new \Exception("SQL error while storing user token");
+        }
+    }
+
+    function getRefreshToken(string $tokenValue): ?RefreshToken {
+
+        $dbh = $this->getDBHandler();
+
+        $stmt = $dbh->prepare("SELECT token, user_id, client_id, creation_time, expiration_time, scope "
+                . " FROM refresh_token WHERE token = :token");
+
+        $stmt->bindParam(':token', $tokenValue);
+
+        $stmt->execute();
+
+        $row = $stmt->fetch();
+        if (!$row) {
+            return null;
+        }
+
+        $token = new RefreshToken();
+        $token->token = $row['token'];
+        $token->userId = $row['user_id'];
+        $token->clientId = $row['client_id'];
+        $token->creationTime = $row['creation_time'];
+        $token->expirationTime = $row['expiration_time'];
+
+        $scope = null;
+        if (isset($row['scope'])) {
+            $scope = $row['scope'];
+        }
+        if ($scope !== null && $scope !== '') {
+            $token->scope = explode(' ', $scope);
+        }
+
+        return $token;
+    }
+
+    function deleteRefreshToken(string $token): void {
+
+        $dbh = $this->getDBHandler();
+
+        $stmt = $dbh->prepare("DELETE FROM refresh_token WHERE token = :token");
+        $stmt->bindParam(':token', $token);
+        $stmt->execute();
+    }
+
+}
diff --git a/classes/model/AccessToken.php b/classes/model/AccessToken.php
index 746848bd8aa793ddb310cbcb71b0d94584d4c404..9e6da47103792e9c8028dd853daa5caee0011b3a 100644
--- a/classes/model/AccessToken.php
+++ b/classes/model/AccessToken.php
@@ -4,14 +4,24 @@ namespace RAP;
 
 class AccessToken {
 
+    private const TOKEN_LIFESPAN = 3600;
+
+    public function __construct() {
+        $this->creationTime = time();
+        $this->expirationTime = $this->creationTime + AccessToken::TOKEN_LIFESPAN;
+    }
+
     public $token;
     public $code;
     public $userId;
     public $creationTime;
     public $expirationTime;
-    public $expired;
     public $redirectUri;
     public $clientId;
     public $scope;
 
+    public function isExpired(): bool {
+        return $this->expirationTime < time();
+    }
+
 }
diff --git a/classes/model/RefreshToken.php b/classes/model/RefreshToken.php
new file mode 100644
index 0000000000000000000000000000000000000000..a0525294da66f0f59b80aa480eac558f4142543c
--- /dev/null
+++ b/classes/model/RefreshToken.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace RAP;
+
+class RefreshToken {
+
+    private const TOKEN_LIFESPAN = 2 * 3600;
+
+    public function __construct() {
+        $this->creationTime = time();
+        $this->expirationTime = $this->creationTime + RefreshToken::TOKEN_LIFESPAN;
+    }
+
+    public $token;
+    public $userId;
+    public $creationTime;
+    public $expirationTime;
+    public $expired;
+    public $clientId;
+    public $scope;
+
+    public function isExpired(): bool {
+        return $this->expirationTime < time();
+    }
+
+}
diff --git a/exec/.htaccess b/exec/.htaccess
new file mode 100644
index 0000000000000000000000000000000000000000..0819c675015a39eeb64ced3cc76d4cb17c3e37f8
--- /dev/null
+++ b/exec/.htaccess
@@ -0,0 +1,4 @@
+# This directory is for programmatical execution of php scripts
+# e.g. generating new keypairs from a cron job
+
+Deny from  all
diff --git a/exec/generate-keypair.php b/exec/generate-keypair.php
new file mode 100644
index 0000000000000000000000000000000000000000..beb7663f9979d5d7f1475d15f7a45e76bdf0c7e7
--- /dev/null
+++ b/exec/generate-keypair.php
@@ -0,0 +1,10 @@
+<?php
+
+chdir(dirname(__FILE__));
+
+include '../include/init.php';
+
+$handler = new \RAP\JWKSHandler($locator);
+$handler->generateKeyPair();
+
+echo "OK";
\ No newline at end of file
diff --git a/include/front-controller.php b/include/front-controller.php
index fc172e27eebb57d8445a0ad556654a4814605924..f90840e403c50a5f31e19e7b387e2c82e8446763 100644
--- a/include/front-controller.php
+++ b/include/front-controller.php
@@ -98,11 +98,27 @@ Flight::route('POST /auth/oauth2/token', function() {
     $params = [
         "grant_type" => filter_input(INPUT_POST, "grant_type", FILTER_SANITIZE_STRING),
         "code" => filter_input(INPUT_POST, "code", FILTER_SANITIZE_STRING),
-        "redirect_uri" => filter_input(INPUT_POST, "redirect_uri", FILTER_SANITIZE_STRING)
+        "redirect_uri" => filter_input(INPUT_POST, "redirect_uri", FILTER_SANITIZE_STRING),
+        "refresh_token" => filter_input(INPUT_POST, "refresh_token", FILTER_SANITIZE_STRING),
+        "scope" => filter_input(INPUT_POST, "scope", FILTER_SANITIZE_STRING)
     ];
 
+    if ($params['grant_type'] === null) {
+        throw new \RAP\BadRequestException("grant_type is required");
+    }
+
     $requestHandler = new \RAP\OAuth2RequestHandler($locator);
-    $token = $requestHandler->handleAccessTokenRequest($params);
+
+    switch ($params['grant_type']) {
+        case "authorization_code":
+            $token = $requestHandler->handleAccessTokenRequest($params);
+            break;
+        case "refresh_token":
+            $token = $requestHandler->handleRefreshTokenRequest($params);
+            break;
+        default:
+            throw new \RAP\BadRequestException("Unsupported grant type " . $params['grant_type']);
+    }
 
     Flight::json($token);
 });
diff --git a/sql/setup-database.sql b/sql/setup-database.sql
index af10551eb6282a8ef44a99598693c718b373d2f2..937698883d0e904e610233befa04659d62b6007e 100644
--- a/sql/setup-database.sql
+++ b/sql/setup-database.sql
@@ -48,11 +48,22 @@ CREATE TABLE `access_token` (
   `id` bigint(20) NOT NULL AUTO_INCREMENT,
   `token` text NOT NULL,
   `user_id` text NOT NULL,
-  `code` text NOT NULL,
+  `code` text,
   `creation_time` BIGINT NOT NULL DEFAULT UNIX_TIMESTAMP(),
-  `expiration_time` BIGINT,
+  `expiration_time` BIGINT NOT NULL,
   `redirect_uri` text,
-  `client_id` varchar(255),
+  `client_id` varchar(255) NOT NULL,
+  `scope` text,
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+
+CREATE TABLE `refresh_token` (
+  `id` bigint(20) NOT NULL AUTO_INCREMENT,
+  `token` text NOT NULL,
+  `user_id` text NOT NULL,
+  `creation_time` BIGINT NOT NULL DEFAULT UNIX_TIMESTAMP(),
+  `expiration_time` BIGINT NOT NULL,
+  `client_id` varchar(255) NOT NULL,
   `scope` text,
   PRIMARY KEY (`id`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
diff --git a/tests/OAuth2RequestHandlerTest.php b/tests/OAuth2RequestHandlerTest.php
index ec58c2fd8db410c554fcbd0e65b43a38e7382970..2efed06a18487180934599b4799433eaf06ab3cf 100644
--- a/tests/OAuth2RequestHandlerTest.php
+++ b/tests/OAuth2RequestHandlerTest.php
@@ -47,6 +47,7 @@ final class OAuth2RequestHandlerTest extends TestCase {
             "redirect_uri" => "redirect_uri",
             "state" => "state",
             "alg" => null,
+            "nonce" => null,
             "scope" => "email%20profile"
         ];