diff --git a/README.md b/README.md
index f74bb5b8a506e0f320373c97063c19e2cf9235eb..90327b13d3d000a5760d02a76702ae3e01b063b0 100644
--- a/README.md
+++ b/README.md
@@ -411,7 +411,7 @@ docker run --name ssp-oauth2-dev \
   -e SSP_ENABLED_MODULES="authoauth2" \
   --mount type=bind,source="$(pwd)/docker/config/authsources.php",target=/var/simplesamlphp/config/authsources.php,readonly \
   --mount type=bind,source="$(pwd)/docker/config/config-override.php",target=/var/simplesamlphp/config/config-override.php,readonly \
-  -p 443:443 cirrusid/simplesamlphp:v2.0.0
+  -p 443:443 cirrusid/simplesamlphp:v2.0.7
 ```
 
 and visit (which resolves to localhost, and the docker container) the [test authsource page](https://oauth2-validation.local.stack-dev.cirrusidentity.com/simplesaml/module.php/admin/test)
diff --git a/composer.json b/composer.json
index 517636c6fd71ad1fb2b4d47110f2e511a9366d0b..61c65e7a6537b3aad62a4ff5a5663069c649d8f1 100644
--- a/composer.json
+++ b/composer.json
@@ -11,7 +11,7 @@
   "require": {
     "php": ">=7.4 || ^8.0",
     "simplesamlphp/composer-module-installer": "^1.1",
-    "league/oauth2-client": "^2.6",
+    "league/oauth2-client": "^2.7",
     "simplesamlphp/simplesamlphp": "^v2.0.0",
     "firebase/php-jwt": "^5.5|^6",
     "kevinrob/guzzle-cache-middleware": "^4.1.1",
diff --git a/docker/config/authsources.php b/docker/config/authsources.php
index d1e2be9965546d5e28eb0add75bd20231965004c..5e4c76542c53d5c54d868bd0875e04209e35337e 100644
--- a/docker/config/authsources.php
+++ b/docker/config/authsources.php
@@ -57,6 +57,18 @@ $config = array(
         'clientSecret' => 'GXc8Q~mgI7kTBllrvpBthUEioeARdjrRYORSyda4',
     ],
 
+    'microsoftOIDCPkceSource' => [
+        'authoauth2:OpenIDConnect',
+        'issuer' => 'https://sts.windows.net/{tenantid}/',
+        // When using the 'common' discovery endpoint it allows any Azure user to authenticate, however
+        // the token issuer is tenant specific and will not match what is in the common discovery document.
+        'validateIssuer' => false,  // issuer is just used to confirm correct discovery endpoint loaded
+        'discoveryUrl' => 'https://login.microsoftonline.com/common/.well-known/openid-configuration',
+        'clientId' => 'f579dc6e-58f5-41a8-8bbf-96d54eacfe8d',
+        'clientSecret' => 'GXc8Q~mgI7kTBllrvpBthUEioeARdjrRYORSyda4',
+        'pkceMethod' => 'S256',
+    ],
+
 
     // This is a authentication source which handles admin authentication.
     'admin' => array(
diff --git a/docs/PKCE.md b/docs/PKCE.md
new file mode 100644
index 0000000000000000000000000000000000000000..4d6efd58ebb2d41e3febbb04ad9a2b6d80ce8bc7
--- /dev/null
+++ b/docs/PKCE.md
@@ -0,0 +1,37 @@
+# PKCE support
+
+PKCE (Proof Key for Code Exchange) is an extension to the OAuth2 protocol that is used to secure the
+authorization code flow against CSRF (cross site request forgery) and code injection attacks.
+PKCE is recommended in almost all OAuth use cases. Some servers or operators require the clients to use PKCE.
+
+## Usage
+
+Enable PKCE by setting the `pkceMethod` configuration key to a valid method (only `S256` is recommended).
+Note: `plain` is also a valid method, but not recommended, see the link to 'thephpleague/oauth2-client' below for details.
+
+### Example configuration
+
+Below is an example demonstrating how to configure the `authoauth2` module for PKCE:
+
+```php
+// config/authsources.php
+
+$config = [
+    'my-oidc-auth-source' => [
+        'authoauth2:OpenIDConnect',
+
+        'issuer' => 'https://my-issuer',
+        'clientId' => 'client-id',
+        'clientSecret' => 'client-secret',
+        
+        // activate PKCE with the S256 method
+        'pkceMethod' => 'S256',
+    ]
+];
+```
+
+## Links
+
+- See https://github.com/thephpleague/oauth2-client/blob/master/docs/usage.md#authorization-code-grant-with-pkce
+  for implementation notes of the underlying library.
+- RFC 7636 for PKCE: https://datatracker.ietf.org/doc/html/rfc7636
diff --git a/src/Auth/Source/OAuth2.php b/src/Auth/Source/OAuth2.php
index 3cdd32b9639815b974c8d1d05049d4e6d31bf967..c233a6c5a4fb564168be394f1759096d5bd8ee32 100644
--- a/src/Auth/Source/OAuth2.php
+++ b/src/Auth/Source/OAuth2.php
@@ -12,7 +12,6 @@ use InvalidArgumentException;
 use League\OAuth2\Client\Provider\AbstractProvider;
 use League\OAuth2\Client\Provider\ResourceOwnerInterface;
 use League\OAuth2\Client\Token\AccessToken;
-use League\OAuth2\Client\Token\AccessTokenInterface;
 use Psr\Http\Message\RequestInterface;
 use SimpleSAML\Auth\Source;
 use SimpleSAML\Auth\State;
@@ -23,7 +22,7 @@ use SimpleSAML\Module\authoauth2\AttributeManipulator;
 use SimpleSAML\Module\authoauth2\ConfigTemplate;
 use SimpleSAML\Module\authoauth2\locators\HTTPLocator;
 use SimpleSAML\Module\authoauth2\Providers\AdjustableGenericProvider;
-use SimpleSAML\Utils\HTTP;
+use SimpleSAML\Session;
 
 /**
  * Authenticate using Oauth2.
@@ -49,6 +48,9 @@ class OAuth2 extends Source
     // phpcs:ignore
     public const DEBUG_LOG_FORMAT = "{method} {uri} {code} {req_headers_Authorization} >>>>'{req_body}' <<<<'{res_body}'";
 
+    private const PKCE_SESSION_NAMESPACE = 'authoauth2_pkce';
+    private const PKCE_SESSION_KEY = 'pkceCode';
+
     protected static string $defaultProviderClass = AdjustableGenericProvider::class;
 
     protected Configuration $config;
@@ -124,6 +126,9 @@ class OAuth2 extends Source
         $authorizeURL = $provider->getAuthorizationUrl($options);
         Logger::debug("authoauth2: $providerLabel redirecting to authorizeURL=$authorizeURL");
 
+        // save the pkce code in the current session, so it can be retrieved later for verification
+        $this->saveCodeChallengeFromProvider($provider);
+
         $this->getHttp()->redirectTrustedURL($authorizeURL);
     }
 
@@ -206,6 +211,10 @@ class OAuth2 extends Source
 
         $provider = $this->getProvider($this->config);
 
+        // load the pkce code from the session, so the server can validate it
+        // when exchanging the authorization code for an access token.
+        $this->loadCodeChallengeIntoProvider($provider);
+
         /**
          * @var AccessToken $accessToken
          */
@@ -359,6 +368,50 @@ class OAuth2 extends Source
         return $decoded;
     }
 
+    protected function isPkceEnabled(): bool
+    {
+        return (bool)$this->config->getOptionalValueValidate('pkceMethod', [
+            AbstractProvider::PKCE_METHOD_PLAIN,
+            AbstractProvider::PKCE_METHOD_S256,
+            ''
+        ], null);
+    }
+
+    /**
+     * support saving the providers PKCE code in the session for later verification.
+     * We store in the session rather in the $state since the $provider generates
+     * the pkce after it has been configured with the $state id, which we get after
+     * saving the $state.
+     */
+    protected function saveCodeChallengeFromProvider(AbstractProvider $provider): void
+    {
+        if ($this->isPkceEnabled()) {
+            Session::getSessionFromRequest()
+                ->setData(
+                    self::PKCE_SESSION_NAMESPACE,
+                    self::PKCE_SESSION_KEY,
+                    $provider->getPkceCode()
+                );
+        }
+    }
+
+    /**
+     * support retrieving the PKCE code from the session for verification.
+     */
+    protected function loadCodeChallengeIntoProvider(AbstractProvider $provider): void
+    {
+        if ($this->isPkceEnabled()) {
+            $pkceCode = (string)Session::getSessionFromRequest()
+                ->getData(
+                    self::PKCE_SESSION_NAMESPACE,
+                    self::PKCE_SESSION_KEY
+                );
+            if ($pkceCode) {
+                $provider->setPkceCode($pkceCode);
+            }
+        }
+    }
+
     /**
      * Allow subclasses to invoked any additional methods, such as extra API calls
      * @param AccessToken $accessToken The user's access token
diff --git a/src/Providers/OpenIDConnectProvider.php b/src/Providers/OpenIDConnectProvider.php
index 23d4d01565d9475395b3a0e3f43d8b5cc3740bc2..0abc2b9a8fb5710a64d9769a2aaab6526bbff84c 100644
--- a/src/Providers/OpenIDConnectProvider.php
+++ b/src/Providers/OpenIDConnectProvider.php
@@ -26,6 +26,8 @@ class OpenIDConnectProvider extends AbstractProvider
 
     protected string $discoveryUrl;
 
+    protected ?string $pkceMethod = null;
+
     /**
      * @var ?Configuration
      */
@@ -258,4 +260,9 @@ class OpenIDConnectProvider extends AbstractProvider
         $config = $this->getOpenIDConfiguration();
         return $config->getOptionalString("end_session_endpoint", null);
     }
+
+    protected function getPkceMethod(): ?string
+    {
+        return $this->pkceMethod ?: parent::getPkceMethod();
+    }
 }
diff --git a/tests/lib/Auth/Source/OAuth2Test.php b/tests/lib/Auth/Source/OAuth2Test.php
index e433638650b0fd7bfd3ff32415b6c2a51048a793..e4ae842b6bc3550169ef7deb9aa6561614da5357 100644
--- a/tests/lib/Auth/Source/OAuth2Test.php
+++ b/tests/lib/Auth/Source/OAuth2Test.php
@@ -12,6 +12,7 @@ use PHPUnit\Framework\TestCase;
 use Psr\Http\Message\RequestInterface;
 use SimpleSAML\Auth\State;
 use SimpleSAML\Module\authoauth2\Auth\Source\OAuth2;
+use SimpleSAML\Session;
 use SimpleSAML\Utils\HTTP;
 use Test\SimpleSAML\MockOAuth2Provider;
 use Test\SimpleSAML\RedirectException;
@@ -416,4 +417,106 @@ class OAuth2Test extends TestCase
         $strHandler = (string)$handlerStack;
         $this->assertStringContainsString('logHttpTraffic', $strHandler);
     }
+
+    /**
+     * test that the authenticate with save the pkce code challenge in the session and
+     * finalStep methods will load the pkce code challenge from the session.
+     *
+     * @psalm-param class-string<\Throwable>|null $expectedException
+     *
+     * @dataProvider dataAuthenticateAndFinalStepWillCallSaveAndRetrieveTheCode
+     */
+    public function testAuthenticateAndFinalStepWillCallSaveAndRetrieveTheCode(
+        ?string $method,
+        int $count,
+        ?string $expectedException
+    ): void {
+        if ($expectedException !== null) {
+            $this->expectException($expectedException);
+        }
+
+        $config = [
+            'providerClass' => MockOAuth2Provider::class,
+        ];
+        if ($method !== 'unset') {
+            $config['pkceMethod'] = $method;
+        }
+
+        $authOAuth2 = $this->getInstance($config);
+
+        $mock = $this->getMockBuilder(AbstractProvider::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+        $accessToken = new AccessToken(['access_token' => 'stubAccessToken', 'id_token' => 'stubIdToken']);
+        $mock->method('getAccessToken')
+            ->willReturn($accessToken);
+
+        $attributes = ['name' => 'Bob'];
+        $user = new GenericResourceOwner($attributes, 'userId');
+        $mock->method('getResourceOwner')
+            ->with($accessToken)
+            ->willReturn($user);
+
+        // use different codes for loading and saving to test both cases
+        $pkceCodeOnAuthenticate = 'thePkceCodeOnAuthenticate';
+        $pkceCodeOnFinalStep = 'thePkceCodeOnFinalStep';
+        $mock
+            ->expects($this->exactly($count))
+            ->method('getPkceCode')
+            ->willReturn($pkceCodeOnAuthenticate);
+
+        $mock
+            ->expects($this->exactly($count))
+            ->method('setPkceCode')
+            ->with($pkceCodeOnFinalStep);
+
+        MockOAuth2Provider::setDelegate($mock);
+
+        // Override redirect behavior of authenticate
+        $http = $this->createMock(HTTP::class);
+        $http->method('redirectTrustedURL')
+            ->willThrowException(
+                new RedirectException('redirectTrustedURL', 'https://mock.com/')
+            );
+        // set the http mock
+        $authOAuth2->setHttp($http);
+
+        // empty the session and check it is empty
+        Session::getSessionFromRequest()->deleteData('authoauth2_pkce', 'pkceCode');
+        static::assertEmpty(Session::getSessionFromRequest()->getDataOfType('authoauth2_pkce'));
+
+        // perform the test
+        $state = [State::ID => 'stateId'];
+        try {
+            $authOAuth2->authenticate($state);
+            $this->fail("Redirect expected");
+        } catch (RedirectException $e) {
+            $sessionData = Session::getSessionFromRequest()->getDataOfType('authoauth2_pkce');
+            if ($count === 0) {
+                static::assertEmpty($sessionData);
+            } else {
+                static::assertEquals($pkceCodeOnAuthenticate, $sessionData['pkceCode']);
+            }
+        }
+
+        // prepare the session for the final step (different code to test, that the session will get used)
+        Session::getSessionFromRequest()->setData('authoauth2_pkce', 'pkceCode', $pkceCodeOnFinalStep);
+        $authOAuth2->finalStep($state, 'theOAuth2Code');
+    }
+
+    /**
+     * @return array<string, array{0: string|null, 1: int, 2: class-string<\Throwable>|null}>
+     */
+    public function dataAuthenticateAndFinalStepWillCallSaveAndRetrieveTheCode(): array
+    {
+        return [
+            'pkceMethod=S256' => ['S256', 1, null],
+            'pkceMethod=plain' => ['plain', 1, null],
+            // exception from underlying AbstractProvider
+            'invalid pkceMethod throws exception' => ['invalid', 0, \InvalidArgumentException::class],
+            'pkceMethod="" means pkce is disabled' => ['', 0, null],
+            'pkceMethod=null means pkce is disabled' => [null, 0, null],
+            'pkceMethod not set means pkce is disabled' => ['unset', 0, null],
+        ];
+    }
 }
diff --git a/tests/lib/MockOAuth2Provider.php b/tests/lib/MockOAuth2Provider.php
index 5b505f20c353d91af7cb58fea8accc6634229197..781bb1844dbb09583972f77763f71b00e4f224a2 100644
--- a/tests/lib/MockOAuth2Provider.php
+++ b/tests/lib/MockOAuth2Provider.php
@@ -54,6 +54,16 @@ class MockOAuth2Provider extends GenericProvider implements ClearableState
         return self::$delegate->getParsedResponse($request);
     }
 
+    public function setPkceCode($pkceCode): void
+    {
+        self::$delegate->setPkceCode($pkceCode);
+    }
+
+    public function getPkceCode()
+    {
+        return self::$delegate->getPkceCode();
+    }
+
     /**
      * Clear any cached internal state.
      */
diff --git a/tests/lib/Providers/OpenIDConnectProviderTest.php b/tests/lib/Providers/OpenIDConnectProviderTest.php
index 64e33f31e73cd745520d6bdfa1bc1eb288ebb4ec..726ba55f5318350a0a19dc6c81e393f772f716e0 100644
--- a/tests/lib/Providers/OpenIDConnectProviderTest.php
+++ b/tests/lib/Providers/OpenIDConnectProviderTest.php
@@ -96,4 +96,26 @@ class OpenIDConnectProviderTest extends TestCase
             $provider->getDiscoveryUrl()
         );
     }
+
+    public function testGetPkceMethodGetsSetFromConfig(): void
+    {
+        $provider = new OpenIDConnectProvider(
+            ['issuer' => 'https://accounts.example.com']
+        );
+        // make the protected getPkceMethod available
+        $reflection = new \ReflectionClass($provider);
+        $method = $reflection->getMethod('getPkceMethod');
+        $method->setAccessible(true);
+        $this->assertNull($method->invoke($provider));
+
+        $provider = new OpenIDConnectProvider([
+            'issuer' => 'https://accounts.example.com',
+            'pkceMethod' => 'S256'
+        ]);
+        // make the protected getPkceMethod available
+        $reflection = new \ReflectionClass($provider);
+        $method = $reflection->getMethod('getPkceMethod');
+        $method->setAccessible(true);
+        $this->assertEquals('S256', $method->invoke($provider));
+    }
 }