Decoding and validating AWS Cognito JWTs with PHP
Sam
Posted on April 8, 2022
Skip to the working sample repository or read on for explanation...
At Unearthed, we use the AWS service Cognito to issue JWTs to clients during authentication. From there, the JWT is exchanged with whichever services the user is interacting with in order to validate their identity. This is helpful when building out a service graph, since each JWT can describe an authenticated users session without any direct dependency on a user service.
If you are decoding Cognito JWTs from PHP, you'll need a few key pieces of information to start:
- The region your Cognito user pool is based in (
us-east-2
in our case). - The User Pool ID the token was issued from.
- The Client ID used to issue the token.
To decode the tokens we'll be using web-token/jwt-framework and a few other dependencies that can be installed with:
composer require web-token/jwt-checker web-token/jwt-signature-algorithm-rsa guzzlehttp/guzzle
The process of decoding and validating a token is:
- Download the public keys used to sign the token.
- Decode the token and validate it against the public keys.
- Verify the claims in the token.
Downloading the keys
Our Cognito configuration can be represented as a simple value object:
<?php
namespace Sam\JwtBlogPost;
class CognitoConfiguration {
public function __construct(
public readonly string $region,
public readonly string $poolId,
public readonly string $clientId,
) {
}
public function getIssuer(): string {
return sprintf('https://cognito-idp.%s.amazonaws.com/%s_%s', $this->region, $this->region, $this->poolId);
}
public function getPublicKeysUrl(): string {
return sprintf('https://cognito-idp.%s.amazonaws.com/%s_%s/.well-known/jwks.json', $this->region, $this->region, $this->poolId);
}
}
Using this configuration and a HTTP client we can implement a key manager to download the keys:
<?php
declare(strict_types=1);
namespace Sam\JwtBlogPost;
use GuzzleHttp\ClientInterface;
use Jose\Component\Core\JWKSet;
class CognitoKeyManager {
public function __construct(private ClientInterface $client, private CognitoConfiguration $configuration) {
}
public function getKeySet(): JWKSet {
return JWKSet::createFromJson($this->retrieveKeys());
}
private function retrieveKeys(): string {
// @todo These keys can be cached.
return (string) $this->client->request('GET', $this->configuration->getPublicKeysUrl())->getBody();
}
}
Decoding and Verifying Claims
From there, the token needs to be decoded, validated against the public keys and the claims in the token need to be validated, to ensure they are valid and originated from your specific Cognito user pool.
Using the key manager and our configuration, here is a working decoder based on Cognito's documentation about how tokens should be decoded and verified.
Key things to note are:
- ID tokens and access tokens have slightly different means of validation (the
aud
andclient_id
claims need to be validated in each, respectively). - The
token_use
claim should be validated as eitherid
oraccess
respectively. - Other standard claims like
iat
,nbf
,exp
andiss
should be validated in both. Cognito's Issuer convention is encapsulated in theCognitoConfiguration
object.
<?php
namespace Sam\JwtBlogPost;
use Jose\Component\Checker\AlgorithmChecker;
use Jose\Component\Checker\AudienceChecker;
use Jose\Component\Checker\ClaimCheckerManager;
use Jose\Component\Checker\ExpirationTimeChecker;
use Jose\Component\Checker\HeaderCheckerManager;
use Jose\Component\Checker\IssuedAtChecker;
use Jose\Component\Checker\IssuerChecker;
use Jose\Component\Checker\NotBeforeChecker;
use Jose\Component\Core\AlgorithmManager;
use Jose\Component\Signature\Algorithm\RS256;
use Jose\Component\Signature\JWS;
use Jose\Component\Signature\JWSLoader;
use Jose\Component\Signature\JWSTokenSupport;
use Jose\Component\Signature\JWSVerifier;
use Jose\Component\Signature\Serializer\CompactSerializer;
use Jose\Component\Signature\Serializer\JWSSerializerManager;
use Sam\JwtBlogPost\Checkers\ClientIdChecker;
use Sam\JwtBlogPost\Checkers\TokenUseChecker;
/**
* Load and verify Cognito tokens.
*
* Rules for verifying tokens are:
* - Verify that the token is not expired.
* - The aud claim in an ID token and the client_id claim in an access token should match the app client ID that was created in the Amazon Cognito user pool.
* - The issuer (iss) claim should match your user pool. For example, a user pool created in the us-east-1 Region will have the following iss value: https://cognito-idp.us-east-1.amazonaws.com/<userpoolID>.
* - Check the token_use claim.
* - If you are only accepting the access token in your web API operations, its value must be access.
* - If you are only using the ID token, its value must be id.
* - If you are using both ID and access tokens, the token_use claim must be either id or access.
*
* @see https://web-token.spomky-labs.com/advanced-topics-1/security-recommendations#loading-process
* @see https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-verifying-a-jwt.html
*/
class CognitoJwtDecoder {
public function __construct(private CognitoKeyManager $keyManager, private CognitoConfiguration $configuration) {
}
public function decodeIdToken(string $token): JWS {
return $this->decodeAndValidate($token, [
new AudienceChecker($this->configuration->clientId),
new TokenUseChecker('id'),
], ['iss', 'aud', 'token_use']);
}
public function decodeAccessToken(string $token): JWS {
return $this->decodeAndValidate($token, [
new ClientIdChecker($this->configuration->clientId),
new TokenUseChecker('access'),
], ['iss', 'client_id', 'token_use']);
}
/**
* @throws \Jose\Component\Checker\InvalidClaimException
* @throws \Jose\Component\Checker\MissingMandatoryClaimException
* @throws \Exception
*/
private function decodeAndValidate(string $token, array $claimChecks, array $mandatoryClaims): JWS {
$headerChecker = new HeaderCheckerManager([new AlgorithmChecker(['RS256'])], [new JWSTokenSupport()]);
$claimChecker = new ClaimCheckerManager(
array_merge([
new IssuedAtChecker(),
new NotBeforeChecker(),
new ExpirationTimeChecker(),
new IssuerChecker([$this->configuration->getIssuer()]),
], $claimChecks)
);
$loader = new JWSLoader(new JWSSerializerManager([new CompactSerializer()]), new JWSVerifier(new AlgorithmManager([new RS256()])), $headerChecker);
$jws = $loader->loadAndVerifyWithKeySet($token, $this->keyManager->getKeySet($token), $signature);
$claims = json_decode($jws->getPayload(), true);
$claimChecker->check($claims, $mandatoryClaims);
return $jws;
}
}
Pulling it all together
With all of these components in place, it's possible to pull together a proof of concept that can validate and decode Cognito JWTs:
<?php
require_once 'vendor/autoload.php';
[, $region, $poolId, $clientId, $type, $token] = $argv;
$config = new \Sam\JwtBlogPost\CognitoConfiguration($region, $poolId, $clientId);
$keyManager = new \Sam\JwtBlogPost\CognitoKeyManager(
new \GuzzleHttp\Client(),
$config,
);
$decoder = new \Sam\JwtBlogPost\CognitoJwtDecoder($keyManager, $config);
var_export($type === 'access' ? $decoder->decodeAccessToken($token) : $decoder->decodeIdToken($token));
Which can be invoked with:
php run.php us-east-2 POOL_ID CLIENT_ID TOKEN_TYPE TOKEN
Yielding a decoded token:
<?php
Jose\Component\Signature\JWS::__set_state(array(
'payload' => '{"sub":"420cd5cc-f537-4eab-a338-dc72f0b048e0","aud"...
It's worth mentioning, if you are using Symfony, there is a Symfony Bundle which will make some of the factories and services used in this blog post available from the container. In our application we decided instantiating these dependencies directly was preferable.
Posted on April 8, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.