diff --git a/classes/Locator.php b/classes/Locator.php
index 4b34c0c888642f12204180881d88799b3da10fac..e1709a200004921c397d95e9eb0f1448bdfb79d1 100644
--- a/classes/Locator.php
+++ b/classes/Locator.php
@@ -94,6 +94,10 @@ class Locator {
         return new ClientAuthChecker($this);
     }
 
+    public function getTokenExchanger(): TokenExchanger {
+        return new TokenExchanger($this);
+    }
+
     /**
      * Retrieve the SessionData object from the $_SESSION PHP variable. Create a
      * new one if it is necessary.
diff --git a/classes/OAuth2RequestHandler.php b/classes/OAuth2RequestHandler.php
index 2b45d367145fccd8a32d64a99aa4f495748738a4..b3f585ee7b0507ca321554324fd001089dadc1f1 100644
--- a/classes/OAuth2RequestHandler.php
+++ b/classes/OAuth2RequestHandler.php
@@ -106,6 +106,8 @@ class OAuth2RequestHandler {
                 return $this->handleClientCredentialsRequest($headers);
             case "refresh_token":
                 return $this->handleRefreshTokenRequest($params, $headers);
+            case "urn:ietf:params:oauth:grant-type:token-exchange":
+                return $this->locator->getTokenExchanger()->exchangeToken($params, $headers);
             default:
                 throw new \RAP\BadRequestException("Unsupported grant type " . $params['grant_type']);
         }
diff --git a/classes/TokenBuilder.php b/classes/TokenBuilder.php
index 22d74f9c7fb28713e9a02a0aedc6765e9f1bc623..64ae0f1c6578240a539c78acaab7eb0ab37dd8c1 100644
--- a/classes/TokenBuilder.php
+++ b/classes/TokenBuilder.php
@@ -117,6 +117,30 @@ class TokenBuilder {
         return $audiences;
     }
 
+    public function generateToken(array $claims) {
+
+        $iat = time();
+
+        // basic payload
+        $payload = array(
+            'iss' => $this->locator->config->jwtIssuer,
+            'iat' => $iat
+        );
+
+        // copy claims passed as parameter
+        foreach ($claims as $key => $value) {
+            $payload[$key] = $value;
+        }
+
+        // set expiration claim if it doesn't exist
+        if (!array_key_exists('exp', $payload)) {
+            $payload['exp'] = $iat + 3600;
+        }
+
+        $keyPair = $this->locator->getJWKSDAO()->getNewestKeyPair();
+        return JWT::encode($payload, $keyPair->privateKey, $keyPair->alg, $keyPair->keyId);
+    }
+
     /**
      * @param int $lifespan in hours
      * @param string $audience target service
diff --git a/classes/TokenChecker.php b/classes/TokenChecker.php
index 370bc8526f6470d145f93e8798ce79d7aed4a74c..946a49664ad38636d148c8d65e94fd074a907454 100644
--- a/classes/TokenChecker.php
+++ b/classes/TokenChecker.php
@@ -26,10 +26,10 @@ class TokenChecker {
             throw new BadRequestException("Invalid token type");
         }
 
-        return $this->attemptJWTTokenValidation($token);
+        return $this->getValidTokenObject($token);
     }
 
-    private function attemptJWTTokenValidation($jwt): object {
+    public function getValidTokenObject(string $jwt): object {
 
         $jwtParts = explode('.', $jwt);
         if (count($jwtParts) === 0) {
@@ -47,7 +47,13 @@ class TokenChecker {
         }
 
         try {
-            return JWT::decode($jwt, $keyPair->publicKey, [$keyPair->alg]);
+            $token = JWT::decode($jwt, $keyPair->publicKey, [$keyPair->alg]);
+
+            if (!isset($token->sub)) {
+                throw new UnauthorizedException("Invalid token: missing subject claim");
+            }
+
+            return $token;
         } catch (\Firebase\JWT\ExpiredException $ex) {
             throw new UnauthorizedException("Access token is expired");
         }
diff --git a/classes/TokenExchanger.php b/classes/TokenExchanger.php
index a397478b8146cf3db6bc4b644618968084779fd1..f37841093eaca595dc0706e36f144bbfbfe05d2c 100644
--- a/classes/TokenExchanger.php
+++ b/classes/TokenExchanger.php
@@ -26,6 +26,9 @@ namespace RAP;
 
 use \Firebase\JWT\JWT;
 
+/**
+ * See https://tools.ietf.org/html/rfc8693
+ */
 class TokenExchanger {
 
     private $locator;
@@ -34,9 +37,62 @@ class TokenExchanger {
         $this->locator = $locator;
     }
 
-    public function exchangeToken(string $token) {
+    public function exchangeToken(array $params, array $headers): array {
+
+        $this->locator->getClientAuthChecker()->validateClientAuth($headers);
+
+        if ($params['subject_token'] === null) {
+            throw new BadRequestException("subject_token is required");
+        }
+        if ($params['subject_token_type'] === null) {
+            throw new BadRequestException("subject_token_type is required");
+        }
+        if (strtolower($params['subject_token_type']) !== 'bearer') {
+            throw new BadRequestException("subject_token_type " . $params['subject_token_type'] . " not supported");
+        }
+
+        $subjectToken = $this->locator->getTokenChecker()->getValidTokenObject($params['subject_token']);
+
+        $claims = array(
+            'sub' => $subjectToken->sub
+        );
+
+        if ($params['resource'] !== null) {
+            $claims['resource'] = $params['resource'];
+        }
+        if ($params['audience'] !== null) {
+            $claims['aud'] = $this->getAudienceClaim($params['audience']);
+        }
+        if ($params['scope'] !== null) {
+            $claims['scope'] = $params['scope'];
+        }
+        
+        $accessToken = $this->locator->getTokenBuilder()->generateToken($claims);
+        
+        $data = [];
+
+        $data['access_token'] = $accessToken;
+        $data['issued_token_type'] = "urn:ietf:params:oauth:token-type:jwt";
+        $data['token_type'] = 'Bearer';
+
+        return $data;
+    }
+
+    private function getAudienceClaim($audienceParam) {
+        $audiences = explode(' ', $audienceParam);
+        if (count($audiences) === 1) {
+            // according to RFC 7519 audience can be a single value or an array
+            return $audiences[0];
+        }
+        return $audiences;
+    }
+
+    /**
+     * DEPRECATED (currently used by portals: to be removed)
+     */
+    public function exchangeTokenOld(string $token) {
 
-        $key = $this->getKeyForToken($token);
+        $key = $this->getExternalKeyForToken($token);
         $decoded = JWT::decode($token, $key->key, ['RS256']);
 
         $subject = $decoded->sub;
@@ -52,7 +108,7 @@ class TokenExchanger {
         return $data;
     }
 
-    private function getKeyForToken(string $token): PublicJWK {
+    private function getExternalKeyForToken(string $token): PublicJWK {
 
         $keys = $this->locator->getJWKSDAO()->getAllPublicJWK();
 
diff --git a/include/front-controller.php b/include/front-controller.php
index 56b2021328db081c0060904d57a757fa96852529..14a205bcc24d5fdd6e166e09c45f03679afa0b6d 100644
--- a/include/front-controller.php
+++ b/include/front-controller.php
@@ -99,7 +99,12 @@ Flight::route('POST /auth/oauth2/token', function() {
         "code" => filter_input(INPUT_POST, "code", 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)
+        "scope" => filter_input(INPUT_POST, "scope", FILTER_SANITIZE_STRING),
+        // For token exchange
+        "resource" => filter_input(INPUT_POST, "resource", FILTER_SANITIZE_STRING),
+        "audience" => filter_input(INPUT_POST, "audience", FILTER_SANITIZE_STRING),
+        "subject_token" => filter_input(INPUT_POST, "subject_token", FILTER_SANITIZE_STRING),
+        "subject_token_type" => filter_input(INPUT_POST, "subject_token_type", FILTER_SANITIZE_STRING)
     ];
 
     $headers = apache_request_headers();
diff --git a/include/rest-web-service.php b/include/rest-web-service.php
index c4e78543b83082f7a3758a46622cec2d89c3dd68..ef1972940d1edcc94c2cc6b1c813ee43929d043a 100644
--- a/include/rest-web-service.php
+++ b/include/rest-web-service.php
@@ -98,5 +98,5 @@ Flight::route('POST ' . $WS_PREFIX . '/exchange', function() {
 
     $exchanger = new \RAP\TokenExchanger($locator);
 
-    Flight::json($exchanger->exchangeToken($subjectToken));
+    Flight::json($exchanger->exchangeTokenOld($subjectToken));
 });