Photo by CMDR Shane on Unsplash

In this third and final post of my AWS Cognito series I’ll write about creating and securing a simple Express based Node.js REST API service by using an AWS Cognito issued JSON Web Token (JWT) access code. We’ll also modify the React UI application we created in the second post of this series to call this REST API and include one of the JWT access codes it received from Cognito.

JWT overview

There is extensive documentation already covering JWT (try https://jwt.io/introduction) so I don’t want to repeat that all here, instead I’ll give a quick overview and then we’ll look at some Cognito specific aspects.

In summary, a JSON Web Token (JWT) is an encoded and digitally signed piece of JSON, which the callee can verify in order to i) check the authorisation of the caller, and ii) get information about the identity of the caller (e.g. name, email address, account id etc).

JWT has several advantages over a more centralised user-registry type of approach, which is why it’s become a very popular solution for API authentication and authorisation. For me the biggest advantages are:

No external calls required to verify a JWT access code. The public key of the signing authority (a Cognito user pool in our example) is downloaded, cached, and then used to verify the signature of JWT access codes on incoming API requests. If the signature is verified then it means the JWT access code could only have been issued from our Cognito user pool. This massively reduces the latency and overall system overhead in verifying an incoming API request and makes it much easier to scale a system.

A JWT access code can include supplementary information about the caller (e.g. name, email address, account id etc). Again, this is all available without the need for any external calls.

JWT access codes typically have an expiry time. I’ve seen implementations using 5 minutes (for system based JWT access codes) to 60 minutes (for user based JWT access codes). After this time they can not be used any longer, which means that unlike the inadvertent exposure of a username/password or API key, the exposure of a JWT access code only has a small time window in which it is usable.

Obviously care should be taken to not expose your user’s JWT access codes in logs, diagnostics, or in any other persistence being done. They should be treated as secrets, just like API keys, passwords etc. And don’t forget they can contain personally identifiable information (PII) and can therefore come under GDPR policies even after they have long expired.

Cognito and JWT

As part of the Cognito UI sign-in flow, our UI application actually receives 3 JWT access codes, as described below.

Cognito ID token

The ID token contains information about the identity of the caller (e.g. name, email address, account id etc). An example of an (expired) encoded JWT ID token from Cognito is shown below:

eyJraWQiOiIwbjd6c2g0eDRlZzVCRmszZk9vNlNLeWkwenJHODNESysyQ1wvNXhtRnFNMD0iLCJhbGciOiJSUzI1NiJ9.eyJhdF9oYXNoIjoiNHhSNmpkODJlRUtiYkFXcnh2cVJlUSIsInN1YiI6IjFmMGJlNjJmLWZmY2QtNDljYS1iNWE0LTE4ZjBiZjYyZTBlNiIsImF1ZCI6IjR0bnA0azY0ZDV2NGFoOWR1ZDNwajFrYnMwIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsInRva2VuX3VzZSI6ImlkIiwiYXV0aF90aW1lIjoxNTY1MDIwNDQ5LCJpc3MiOiJodHRwczpcL1wvY29nbml0by1pZHAuZXUtd2VzdC0yLmFtYXpvbmF3cy5jb21cL2V1LXdlc3QtMl96MUdvNVhkcloiLCJjb2duaXRvOnVzZXJuYW1lIjoiYXJyb24iLCJleHAiOjE1NjUwMjQwNDksImlhdCI6MTU2NTAyMDQ0OSwiZW1haWwiOiJkZW1vQGFycm9uaGFyZGVuLmNvbSJ9.kh_Bdao1BzXIoGvcE8ByAAFdLYb8s_Tcf0RwKk6joJr43r0Q0N7Yz63c5O4DYoUKgwdHRNhKax93rkSCamabmNl8f3-K68OOC3MNn22Fn2p6prOn9jXeK4y_QDHzOj8LFSJTsQxjIq6JfaZReTfHdplEsIVfmqijFO7PPQHcK4GCgYCTxiyD8iaKF48tcdNrm9NczgtzVdT6ShNK8LRcGhdXSYxLk0h0k8lYRhV8Imy2uxAvuY9wgeKvrY7FPHJc8dsgfVuRXAn-QCtEaTCxpOlVuNgtJ06Ww09ur1eINIFFQ_WCqUV1vP7JQEylItrf1WxAYKJDNrKqWSuZeogr3A

Using jwt.io, we can decode this and see that the header contains the following information about how the JWT access code was constructed:

{

"kid": "0n7zsh4x4eg5BFk3fOo6SKyi0zrG83DK+2C/5xmFqM0=",

"alg": "RS256"

}

and the payload contains the following identity information:

{

"at_hash":"4xR6jd82eEKbbAWrxvqReQ",

"sub":"1f0be62f-ffcd-49ca-b5a4-18f0bf62e0e6",

"aud":"4tnp4k64d5v4ah9dud3pj1kbs0",

"email_verified":true,

"token_use":"id",

"auth_time":1565020449,

"iss":"https://cognito-idp.eu-west-2.amazonaws.com/eu-west-2_z1Go5XdrZ",

"cognito:username":"arron",

"exp":1565024049,

"iat":1565020449,

"email":"demo@arronharden.com"

}

The attribute names follow the standard JWT naming convention. Some of the useful attributes to pick out are:

sub is the unique and invariant ID representing the user. When persisting something associated with a user, use the value from this attribute rather than username or email address since the user will then be free to change the latter should they wish to.

is the unique and invariant ID representing the user. When persisting something associated with a user, use the value from this attribute rather than username or email address since the user will then be free to change the latter should they wish to. aud is the client ID used to obtain the JWT access code. This should match the client ID defined in the user pool.

is the client ID used to obtain the JWT access code. This should match the client ID defined in the user pool. token_use describes what type of JWT access code it is — ID token or access token.

describes what type of JWT access code it is — ID token or access token. iss is the issuers, which for Cognito is the URL of the user pool that created the JWT access code. This should match your user pool.

is the issuers, which for Cognito is the URL of the user pool that created the JWT access code. This should match your user pool. cognito:username is the custom Cognito attribute which contains the user name. The user may wish to change this, so avoid persisting it in your application.

is the custom Cognito attribute which contains the user name. The user may wish to change this, so avoid persisting it in your application. exp is the expiry timestamp, after which the JWT access code should no longer be trusted.

is the expiry timestamp, after which the JWT access code should no longer be trusted. email is the current email address of the user. The user may wish to change this, so avoid persisting it in your application.

Depending how the Cognito user pool is configured, the ID token can contain even more identity information, such as full name, telephone number etc but in this example the user pool is only configured to store name and email address.

Cognito access token

An access token contains information about the access rights of the caller, and includes the OAuth scope used to obtain the token. A scope attribute present in the payload will be used to decide which API(s) the caller is permitted to invoke. For example, if the scope in the payload was to only allow hello-world.read-only but the API being called was a POST /users in order to create a new resource — the implementation would be expected to return an HTTP 403 (Forbidden) response to indicate the caller has insufficient permissions for that particular API call.

An example of the decoded payload for an access token is:

{

"sub":"1f0be62f-ffcd-49ca-b5a4-18f0bf62e0e6",

"token_use":"access",

"scope":"openid profile https://cognito-demo-api.arronharden.com/hello-world.all email",

"auth_time":1565020449,

"iss":"https://cognito-idp.eu-west-2.amazonaws.com/eu-west-2_z1Go5XdrZ",

"exp":1565024049,

"iat":1565020449,

"version":2,

"jti":"bb8bade7-2514-4152-8a41-a1524bf43f21",

"client_id":"4tnp4k64d5v4ah9dud3pj1kbs0",

"username":"arron"

}

Note that when comparing the payload of an access token with the ID token how the name of some of the attributes containing the same information are different, for example client_id vs aud and username vs cognito:username . If required, the token_use attribute can be used to determine which type of JWT access code has been supplied.

Cognito refresh token

The third JWT access code our UI receives from Cognito is a refresh token. This token is used to obtain a new ID token and access token once the originals expire. The refresh token is actually encrypted, meaning only the Cognito service is able to see the contents of the payload (you can confirm this by trying jwt.io, which is also not able to decode it). The refresh token itself has a much longer life, measured in days rather than minutes and so for this reason extra care must be taken to keep the refresh token secret.

Verifying a Cognito JWT access code

JWKS download

The first thing we need to do before we’re able to verify anything it to do a one-time download of the public key information associated with our Cognito user pool instance. This is information is called the JSON Web Key Set (JWKS) and it can be downloaded by doing a GET request on an URL constructed using the region and ID of your Cognito user pool:

Putting in the value for the user pool we created in the first post of the series gives me the URL https://cognito-idp.eu-west-2.amazonaws.com/eu-west-2_z1Go5XdrZ/.well-known/jwks.json. Since these are public keys they can be downloaded without any authorisation tokens — the URL will return the following JWKS content:

{

"keys":[

{

"alg":"RS256",

"e":"AQAB",

"kid":"0n7zsh4x4eg5BFk3fOo6SKyi0zrG83DK+2C/5xmFqM0=",

"kty":"RSA",

"n":"tKQOwFsFCRSNiL18QN77UaYYQvBvgIr-ImRW-9Y6KnfYeS5Tk-O39JjllVCYNf6EB5xcZELQuaJmh_bm8uiOxFuk1t6pCghyQWUWU_uR8BRhrqxF-ugWbnWWfkGqPjMD9Tc3oGVIZRwqpXSXkGaid6DaqhkQIzrYQtPwUMge4w4oQgl5KuowBxrxjJdrRChi4FPABNoRaB8VJOHAgvoobP9VACRkooSXKz5b0aCzExuv5mkg8WnhJi_xQCjX41QX3RbpwvgoqPrCxYmCjngy5qwItsJSOozLdxwFp6EDQExp5VwRfd0xU112Ny0ea-EeDlgkQMgVLfvb7cOm74f5WQ",

"use":"sig"

},

{

"alg":"RS256",

"e":"AQAB",

"kid":"iPeYlnLRSbCoyi+JdywuePKNdluh4+7tIPaOn8yWZuE=",

"kty":"RSA",

"n":"iPu6YsSrKbVMMMmhwcyQjdzJHY9y53nEj-oHF3VimQu8gZ0qXaQRckJudZ0AOzDHZvSfdP2mLUMa6HWa_1n7NNsBWCRWTjqOXex2iAOX2Ryo9Sa_pRmXOCEPAbQor2YdYQdKqetbUllhPXmzTRtCGEcXbn8bB40rhAAcRoFUhBqWyUxIQPzwZlhTzk41u5E0V3iIt2jnFTTXfxgHj2571VTCHYTyqdOqcmdx4zvVaY8SUEg-VFRy4GM76JBEgttv7AhPtRlGkhKgIpPb8UJPDXMdtfFaHPzWOGGe9qh1YsuCeEqIUCwTmhy4sO8yVn6ylhJZpFAF8zQLtxIvYRwzdw",

"use":"sig"

}

]

}

There are various techniques for managing the downloaded JWKS content. For this example we download it once at application startup, convert the keys into PEM containers (required by the JWT library we use later) and keep it in memory.

JWT verification

In order to verify a given JWT access code can be trusted, we need to perform a few tests on it before any requested API action is allowed to proceed.

Verify the access code can be decoded using the JWT library. This verifies the format is good, but does not (yet) verify the contents have not been tampered with by a third party. Verify the header of the JWT access code contains a kid (key ID) that matches one returned in the JWKS content for our Cognito user pool. Use the JWT library to verify the access code. This will verify that:

The signature can be validated using the public keys we previously downloaded and cached from our Cognito user pool’s JWKS content. This confirms there has been no tampering as well as confirming it was signed by our user pool instance.

The issuer claim ( iss ) matches our Cognito user pool instance.

) matches our Cognito user pool instance. The access code has not expired.

If all of the above succeeds, we can be certain that the JWT access code was generated from our Cognito user pool and has not been tampered with. We can therefore trust all content in the payload of the JWT access code.

Creating a JWT secured REST API

Now that we know what we need to do in order to verify a JWT access code, we can begin to code it. We start off by using the express CLI command to create a new skeleton Express application.

express --no-view --git cognito-demo-service

This gives us a ready made Node.js Express application which listens on the end point /users .

Since we want to be able to run this locally at the same time as the UI application we change it to listen on port 3010 instead of 3000 (modify /bin/www ). We also make use of the cors package so that when the REST API is hosted on https://cognito-demo-api.arronharden.com it can be invoked from the UI which is hosted on a different domain; https://cognito-demo.arronharden.com. This requires a small addition to app.js to add in the cors middleware:

// Configure CORS for this service so our UI can make calls to us.

var corsOptions = {

origin: [appConfig.signoutUri]

}

app.use(cors(corsOptions))

Express middleware

Next up we define our Express middleware. This will do 2 things:

Verify the JWT access code on the incoming REST API call. We’ll define this to be set on the Authorization header and include the prefix Bearer followed by the JWT access code.

header and include the prefix followed by the JWT access code. Extract the claims from the JWT access code and set them into an object named user on the incoming request object. This allows any downstream middleware or handler to access the decoded information directly from the request.

We define our middleware in self-contained module, the entry point of which is getVerifyMiddleware() . This will start the download of the JWKS content and return the middleware function to be used by Express.

Create and return the verify middleware

The middleware function is easily plugged into Express by including it before the router for the /users endpoint in app.js :

const cognitoAuthMiddleware = cognitoAuth.getVerifyMiddleware()

app.use('/users', cognitoAuthMiddleware, usersRouter)

When the middleware is invoked, the _verifyMiddleware() function is called which verifies the Authorization header and if successful adds the decoded information into the request object, before calling the next Express handler in the chain, using next() . If on the other hand the verification is unsuccessful, it immediately returns an HTTP 401 (Unauthorised) or an HTTP 500 (Internal server error) response to the caller as appropriate, without calling the next handler.

Entry point when middleware is invoked — _verifyMiddleware()

The actual verification of the Authorization header mentioned above is handled by the _verifyProm() function, which performs the JWT access code verification steps described previously and returns a Promise which is resolved or rejected depending on whether the verification was successful or not.

Verification of the Authorization header — _verifyProm()

One point worth mentioning is that in the failure case we keep most of the diagnostic and error information in our private application logs. The error returned to the caller of the REST API contains minimal information, since we don’t want to be too helpful in case we inadvertently help a malicious user.

Using claims in the API

So far all we’ve done is to allow or reject an incoming API call, which is great, but we can now also use the claims in the JWT access code in the API implementation itself. We can do this by simply inspecting the contents of the user object that was added to the request by our middleware. In this example we’ll simply return the contents of the user object in our response to demonstrate how it can be accessed from downstream handlers:

/* GET users listing. */

router.get('/', function (req, res, next) {

// Here we can check the req.user.scope array contains the scope

// relevant for the REST API operation being invoked

res.send('Successfully verified JWT token. Extracted information '

+ JSON.stringify(req.user))

})

Invoke using curl

With those changes in place we now ready to try and call the /users API. But before we start changing the UI to do this I like to make sure what we have in this service works as we expect, so we can try a couple of quick curl commands to try this out.

In the success case we pass a valid bearer token in the Authorization header:



Successfully verified JWT token. Extracted information: {"sub":"1f0be62f-ffcd-49ca-b5a4-18f0bf62e0e6","token_use":"access","scope":["openid","profile","https://cognito-demo-api.arronharden.com/hello-world.all","email"],"username":"arron"} $ curl -H "Authorization:Bearer eyJraWQiOiJpUGVZbG5MUlNiQ295aStKZHl3dWVQS05kbHVoNCs3dElQYU9uOHlXWnVFPSIsImFsZyI6IlJTMjU2In0.eyJzdWIiOiIxZjBiZTYyZi1mZmNkLTQ5Y2EtYjVhNC0xOGYwYmY2MmUwZTYiLCJ0b2tlbl91c2UiOiJhY2Nlc3MiLCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIGh0dHBzOlwvXC9jb2duaXRvLWRlbW8tYXBpLmFycm9uaGFyZGVuLmNvbVwvaGVsbG8td29ybGQuYWxsIGVtYWlsIiwiYXV0aF90aW1lIjoxNTY1Nzk0Nzg3LCJpc3MiOiJodHRwczpcL1wvY29nbml0by1pZHAuZXUtd2VzdC0yLmFtYXpvbmF3cy5jb21cL2V1LXdlc3QtMl96MUdvNVhkcloiLCJleHAiOjE1NjU3OTgzODcsImlhdCI6MTU2NTc5NDc4NywidmVyc2lvbiI6MiwianRpIjoiMDJkODk3OTQtMzIyNy00MTVlLWE1N2UtNGI1YzhlZmU2MGVjIiwiY2xpZW50X2lkIjoiNHRucDRrNjRkNXY0YWg5ZHVkM3BqMWticzAiLCJ1c2VybmFtZSI6ImFycm9uIn0.FmV1wxFHuZWYhVIdR9JnVHBzhbaoJlaUe-aw3yUte__2tRHo-zw6KyyDXQycdZ0JOo8S054mtESdYhJTc4-WTn89ymKUiXLgJXooA7w0cSI2vku1te97loPr5qqh-pnvrtf2XadDjp63lEeUG3Hq9HZEGDbyEUURPL5jMPZrbUnJevKanmPNfJRouHv2OBK6fzq79LnllmGi-_iQo_hu5NBfR6Erx33JbPcVBnw8t9o0JF4rqNPJbYT7qIXXNpRJtMzYVeY6hgfo85eq1h83DouTP3qmyiWYXBg98FoEXq-ROHq2g1E1qPasdi6IO0afqqrkYG8QaNfgGxomlehDtg" http://localhost:3010/users Successfully verified JWT token. Extracted information: {"sub":"1f0be62f-ffcd-49ca-b5a4-18f0bf62e0e6","token_use":"access","scope":["openid","profile","https://cognito-demo-api.arronharden.com/hello-world.all","email"],"username":"arron"}

and not forgetting the failure case, we pass an invalid JWT access code:



Authorization header contains an invalid JWT token. $ curl -H "Authorization:Bearer badbadbad" http://localhost:3010/users Authorization header contains an invalid JWT token.

In the Node.js console we can also see the corresponding debug output, showing the 200 HTTP response and the 401 HTTP response:



GET /users 200 1.108 ms - 238

Invalid JWT token. jwt.decode() failure.

GET /users 401 0.882 ms - 51 Valid JWT token. Decoded: {"sub":"1f0be62f-ffcd-49ca-b5a4-18f0bf62e0e6","token_use":"access","scope":"openid profile https://cognito-demo-api.arronharden.com/hello-world.all email","auth_time":1565794787,"iss":" https://cognito-idp.eu-west-2.amazonaws.com/eu-west-2_z1Go5XdrZ ","exp":1565798387,"iat":1565794787,"version":2,"jti":"02d89794-3227-415e-a57e-4b5c8efe60ec","client_id":"4tnp4k64d5v4ah9dud3pj1kbs0","username":"arron"}.GET /users1.108 ms - 238Invalid JWT token. jwt.decode() failure.GET /users0.882 ms - 51

Calling a JWT secured REST API from a UI

Now we have our backed REST API service in place, it’s a relatively simple matter to make use of it from our example React UI.

Firstly, we modify the getCognitoSession() function to return the JWT access codes so they are available in the Redux store. Then we update the Home component so that if the user is logged in, the /users API is invoked and the response stored using the setState() method.

Home component calling REST API with JWT access code

All that’s left is to just update the render() method of the Home component to show the response we get back from the API call. All going well, this should be the same text we saw returned from our curl example above.

Home component rendering REST API response

Running the UI and doing the sign-in now shows the Home component with the response coming back from the REST API, showing the JWT access code was sent, decoded, verified and claims extracted:

Updated React UI showing successful API response

Summary

We have explored the creation of an AWS Cognito user pool, it’s integration with a React based single-page-application, and now the use of a backend REST API secured by Cognito issued JWT access codes.

We could have alternatively used the AWS API Gateway service to do some of the JWT work for us but I wanted to dive into the details of JWT in this post, so I’ll leave the exploration of the API Gateway service for another time.

Source

All of the source code in this series of posts is available in GitHub:

React UI application source: https://github.com/arronharden/cognito-demo-ui

Express based REST API source: https://github.com/arronharden/cognito-demo-service

Live Instances

Instances of both these Node.js applications are running in AWS and I will endeavour to keep them running for as long as my free account stays active for: