JWT Authorization for serverless APIs on AWS Lambda
Thomas Maximini / March 11, 2020
13 min read
Serverless functions allow us to write small contained API endpoints for our apps. In this post we are going to learn how to secure our serverless API endpoint with a json web token (JWT) based authorization.
TL;DR
If you want to jump straight to the final code, you can find the repo here: https://github.com/tmaximini/serverless-jwt-authorizer
Read on for a full explanation of what is going on here.
Steps for JWT authorization
These are roughly the steps that we have to go through in order to secure our API endpoint:
- Register with username, password, password hash gets stored in DB
- Login with Username / Password
- If hash of password matches stored passwordHash for user, generate a JWT token from user's id and their auth scope
- Save token in Cookie 🍪
- Sign every request with this token in the HTTP Authorization header
- Setup authorizer function that verifies this token (on requesting a secured api route). authorizer response can be cached for a certain amount to increase api throughput.
- Authorizer generates a policyDocument that allows or denies access to the service
Plan our app
We are going to need a registerUser
and a loginUser
method. We will also have an protected /me
endpoint, that returns the the current user object if the user is authenticated correctly.
The verifyToken
is an additional lambda function, that is defined as an API gatewa authorizer and will get called in the background whenever we try to access the protected /me
endpoint.
So we have a total of 4 lambda functions:
Setup our app with serverless framework
So let's initalize the app. You will find the final code of the example in github. We can run serverless init --template aws-nodejs
to bootstrap a node.js based project. Make sure you've setup the AWS cli before or at least you have a ~/.aws/credentials
folder set up because this is where serverless will pull your information from.
Now we go and update the generated serverless.yml
file. We are going to add all our functions from step 1 (register, login, me, verifyToken to it). It should look similar to this one:
org: your-org
service: serverless-jwt-authorizer
provider:
name: aws
runtime: nodejs12.x
region: eu-central-1
functions:
verify-token:
handler: functions/authorize.handler
me:
handler: functions/me.handler
events:
- http:
path: me
method: get
cors: true
authorizer:
name: verify-token
# this tells the lambda where to take the information from,
# in our case the HTTP Authorization header
identitySource: method.request.header.Authorization
resultTtlInSeconds: 3600 # cache the result for 1 hour
login:
handler: functions/login.handler
events:
- http:
path: login
method: post
cors: true
register:
handler: functions/register.handler
events:
- http:
path: register
method: post
cors: true
Folder structure for serverless APIs
The way I do it is to have a single file in ./functions
for each Lambda. Of course you can export multiple functions from the same file but like this I keep sanity and it makes naming easier (each file exports a handler function that I use as the handler in serverless.yml).
All the helpers and non-lambda functions go into the ./lib
folder.
.
├── Readme.md
├── functions
│ ├── authorize.js
│ ├── login.js
│ ├── me.js
│ └── register.js
├── handler.js
├── lib
│ ├── db.js
│ └── utils.js
├── package.json
├── secrets.json
├── serverless.yml
└── yarn.lock
The database layer
Now, before we can authorize a user, we are going to need a way to create a user and save them in the DB. We are going to pick DynamoDB as a database here because being a serverless database itself it is an excellent choice for serverless. Of course you could use any other database as well.
DynamoDB
Amazon DynamoDB is a key-value and document database that delivers single-digit millisecond performance at any scale
DynamoDB works with a single table design. In our case, we just need a users table. I picked DynamoDB here because it is a famous and reliable choice for serverless APIs, especially because of the "pay as you go, scale as you grow" idea behind it.
If you want to know the ins and outs of DynamoDB I recommend you head over to https://www.dynamodbguide.com/ by @alexbdebrie.
The DB Model
When designing a service or an api I like to start with the data model. This is especially important with DynamoDB where we are limited by the single table design. This is why DynamoDB experts tell you to first write down all the access patterns and ways how you plan to query your data. Based on that you are going to model your table.
In our case, the schema is fairly simple for now, but we keep it generic enough to be able to extend it later on. I am using the dynamodb-toolbox package here to define my data model and simplify writing queries.
const { Model } = require('dynamodb-toolbox');
const User = new Model('User', {
// Specify table name
table: 'test-users-table',
// Define partition and sort keys
partitionKey: 'pk',
sortKey: 'sk',
// Define schema
schema: {
pk: { type: 'string', alias: 'email' },
sk: { type: 'string', hidden: true, alias: 'type' },
id: { type: 'string' },
passwordHash: { type: 'string' },
createdAt: { type: 'string' },
},
});
We will obviously not store the password in clear text in our database, so we use bcrypt (footnote about bcryptjs is the better choice on lambda) to create a passwordHash
and then delete the original plain text password from the props object before spreading it into our user.
I chose the email here as a primary key and not the id because this is what I am using to query single items. You yould also use the userId or any combination.
It's important to not that DynamoDB can not fetch single items by non key properties, e.g. in the example above I am not able to say getById(id)
. I would have to fetch them first and then filter by using a FilterExpression.
The advantage of a NoSQL database such as DynamoDB is that columns and fields are dynamic. So if we decide to send more data to the createDbUser
method they'll all get added to the database (We have to adjust the DB Model
from dynamodb-toolkit
first though).
Defining resources in serverless.yml
When we decided on our data model and table name it makes sense to revisit our serverless.yml
and prepare the DynamoDB resource there, so we won't have to do any manual work from the AWS console. The serverless framework allows us to define resources and permissions right from the serverless.yml
file.
We will also need a few secret environment variables. A simple way to define them is just creating a secrets.json file in your project root (make sure to .gitignore it!) and define them in a json format.
org: your-org
custom:
secrets: ${file(secrets.json)}
tableName: 'test-users-table'
service: serverless-jwt-authorizer
provider:
name: aws
runtime: nodejs12.x
region: eu-central-1
environment:
JWT_SECRET: ${self:custom.secrets.JWT_SECRET}
AWS_ID: ${self:custom.secrets.AWS_ID}
iamRoleStatements:
- Effect: 'Allow'
Action:
- 'dynamodb:GetItem'
- 'dynamodb:PutItem'
Resource: 'arn:aws:dynamodb:eu-central-1:${self:custom.secrets.AWS_ID}:table/${self:custom.tableName}'
functions:
verify-token:
handler: functions/authorize.handler
me:
handler: functions/me.handler
events:
- http:
path: me
method: get
cors: true
authorizer:
name: verify-token
identitySource: method.request.header.Authorization
resultTtlInSeconds: 3600
login:
handler: functions/login.handler
events:
- http:
path: login
method: post
cors: true
register:
handler: functions/register.handler
events:
- http:
path: register
method: post
cors: true
resources:
Resources:
usersTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: ${self:custom.tableName}
AttributeDefinitions:
- AttributeName: pk
AttributeType: S
- AttributeName: sk
AttributeType: S
KeySchema:
- AttributeName: pk
KeyType: HASH
- AttributeName: sk
KeyType: RANGE
ProvisionedThroughput:
ReadCapacityUnits: 1
WriteCapacityUnits: 1
User registration
In order to let a user register for our service, we need to store their data in our database. With our data model in place, we can now use AWS DynamoDB DocumentClient together with our dynamodb-toolkit to simplify this process. Take a look at the following code:
// lib/db.js
const AWS = require('aws-sdk');
const bcrypt = require('bcryptjs');
const { Model } = require('dynamodb-toolbox');
const { v4: uuidv4 } = require('uuid');
const User = new Model('User', {
// Specify table name
table: 'test-users-table',
// Define partition and sort keys
partitionKey: 'pk',
sortKey: 'sk',
// Define schema
schema: {
pk: { type: 'string', alias: 'email' },
sk: { type: 'string', hidden: true, alias: 'type' },
id: { type: 'string' },
passwordHash: { type: 'string' },
createdAt: { type: 'string' },
},
});
// INIT AWS
AWS.config.update({
region: 'eu-central-1',
});
// init DynamoDB document client
const docClient = new AWS.DynamoDB.DocumentClient();
const createDbUser = async (props) => {
const passwordHash = await bcrypt.hash(props.password, 8); // hash the pass
delete props.password; // don't save it in clear text
const params = User.put({
...props,
id: uuidv4(),
type: 'User',
passwordHash,
createdAt: new Date(),
});
const response = await docClient.put(params).promise();
return User.parse(response);
};
// export it so we can use it in our lambda
module.exports = {
createDbUser,
};
This is enough for creating our user registration on the database side.
Now let's add the implementation for the actual lambda endpoint.
When getting triggered by an HTTP post, we want to extract the user data from the request body and pass it to the createDbUser
method from our lib/db.js.
Let's create a file called functions/register.js
that looks like this:
// functions/register.js
const { createDbUser } = require('../lib/db');
module.exports.handler = async function registerUser(event) {
const body = JSON.parse(event.body);
return createDbUser(body)
.then((user) => ({
statusCode: 200,
body: JSON.stringify(user),
}))
.catch((err) => {
console.log({ err });
return {
statusCode: err.statusCode || 500,
headers: { 'Content-Type': 'text/plain' },
body: { stack: err.stack, message: err.message },
};
});
};
We are trying to create the user, and if everything goes well we send the user object back with a 200 success status code, otherwise we send an error response.
Next, we are looking to implement the login.
Logging in users
First, we need to extend our lib/db.js helpers file with a function that retrieves an user by email, so we can check if the user exists and if so compare the passwordHash to the hash of the password that was sent with the request.
//...
const getUserByEmail = async (email) => {
const params = User.get({ email, sk: 'User' });
const response = await docClient.get(params).promise();
return User.parse(response);
};
// don't forget to export it
module.exports = {
createDbUser,
getUserByEmail,
};
Now we can import and use this function in our user lambda.
Let's break down the steps we need for logging in the user:
- get email & password from request payload
- try to get user record from database for email
- if found, hash password and compare with passwordHash from user record
- if password is correct, create a valid jwt session token and send it back to the client
Here is the implementation of the login
handler:
// ./functions/login.js
const { login } = require('../lib/utils');
module.exports.handler = async function signInUser(event) {
const body = JSON.parse(event.body);
return login(body)
.then((session) => ({
statusCode: 200,
body: JSON.stringify(session),
}))
.catch((err) => {
console.log({ err });
return {
statusCode: err.statusCode || 500,
headers: { 'Content-Type': 'text/plain' },
body: { stack: err.stack, message: err.message },
};
});
};
// ./lib/utils.js
async function login(args) {
try {
const user = await getUserByEmail(args.email);
const isValidPassword = await comparePassword(args.password, user.passwordHash);
if (isValidPassword) {
const token = await signToken(user);
return Promise.resolve({
auth: true,
token: token,
status: 'SUCCESS',
});
}
} catch (err) {
console.info('Error login', err);
return Promise.reject(new Error(err));
}
}
function comparePassword(eventPassword, userPassword) {
return bcrypt.compare(eventPassword, userPassword);
}
With registration and login in place, we can now proceed to implement a protected API endpoint.
Protected endpoints
So let's say we have a protected resource in our API. A user profile might be a good example. We only want logged in users to be able to see and update their profile information. Let's implement a /me
endpoint that just returns the user record of the currently logged in user from the database.
Here are the steps we need to implement:
- validate jwt token (done by our lamda authorizer function)
- get related user from database
- return user
Sounds simple right? Let's take a look:
// ./functions/me.js
const { getUserByEmail } = require('../lib/db');
const { getUserFromToken } = require('../lib/utils');
module.exports.handler = async function (event) {
const userObj = await getUserFromToken(event.headers.Authorization);
const dbUser = await getUserByEmail(userObj.email);
return {
statusCode: 200,
headers: {},
body: JSON.stringify(dbUser),
};
};
// ./lib/utils.js
async function getUserFromToken(token) {
const secret = Buffer.from(process.env.JWT_SECRET, 'base64');
const decoded = jwt.verify(token.replace('Bearer ', ''), secret);
return decoded;
}
The implementation of /me
is fairly short and straightforward. The way AWS authorizers work is by using policy documents.
The policyDocument has to contain the following information:
- Resource (The ARN orAmazon resource name, a unique identifier of a AWS resource)
- Effect (either
"allow"
or"deny"
) - Action (a keyword that describes the desired action, in our case
"execute-api:Invoke"
The authorizer function
const jwt = require('jsonwebtoken');
function generateAuthResponse(principalId, effect, methodArn) {
const policyDocument = generatePolicyDocument(effect, methodArn);
return {
principalId,
policyDocument,
};
}
function generatePolicyDocument(effect, methodArn) {
if (!effect || !methodArn) return null;
const policyDocument = {
Version: '2012-10-17',
Statement: [
{
Action: 'execute-api:Invoke',
Effect: effect,
Resource: methodArn,
},
],
};
return policyDocument;
}
module.exports.verifyToken = (event, context, callback) => {
const token = event.authorizationToken.replace('Bearer ', '');
const methodArn = event.methodArn;
if (!token || !methodArn) return callback(null, 'Unauthorized');
const secret = Buffer.from(process.env.JWT_SECRET, 'base64');
// verifies token
const decoded = jwt.verify(token, secret);
if (decoded && decoded.id) {
return callback(null, generateAuthResponse(decoded.id, 'Allow', methodArn));
} else {
return callback(null, generateAuthResponse(decoded.id, 'Deny', methodArn));
}
};
Deploy and test
Now, let's run sls deploy
and deploy our final service to AWS. The output should look like the following:
You'll have 3 endpoints, just as we defined them, one for /register, one for /login and one for /me.
First, let's register a user using cURL:
curl -H "Content-Type: application/json" -X POST -d "{\"email\": \"test@example.com\", \"password\": \"test123\"}" https://abc1234567.execute-api.eu-central-1.amazonaws.com/dev/register
We can use the the same cURL command for login, just change /register to /login at the end:
curl -H "Content-Type: application/json" -X POST -d "{\"email\": \"test@example.com\", \"password\": \"test123\"}" https://abc1234567.execute-api.eu-central-1.amazonaws.com/dev/login
This should return a token:
{"auth":true,"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRtYXhpbWluaUBnbWFpbC5jb20iLCJpZCI6ImI5Zjc2ZjUzLWVkNjUtNDk5Yi04ZTBmLTY0YWI5NzI4NTE0MCIsInJvbGVzIjpbIlVTRVIiXSwiaWF0IjoxNTgzMjE4OTk4LCJleHAiOjE1ODMzMDUzOTh9.noxR1hV4VIdnVKREkMUXvnUVUbDZzZH_-LYnjMGZcVY","status":"SUCCESS"}
This is the token we are going to use for requests to the protected API endpoints. Usually you would store this in a client side cookie and add it as an Authorization header to your future requests.
And finally, let's use the token to test our protected endpoint. We can pass in the custom header to curl by using the -H
option:
curl -H "Authorization: <YOUR_TOKEN>" https://myn3t4rsij.execute-api.eu-central-1.amazonaws.com/dev/me
When everything went well, it should return our user record:
{"passwordHash":"$2a$08$8bcT0Uvx.jMPBSc.n4qsD.6Ynb1s1qXu97iM9eGbDBxrcEze71rlK","createdAt":"Wed Mar 04 2020 12:25:52 GMT+0000 (Coordinated Universal Time)","email":"test@example.com","id":"2882851c-5f0a-479a-81a4-e709baf67383"}
Conclusion
Congratulations. You've learned how to design and deploy a microservice to AWS Lambda with JWT authorization. If you made it this far please consider following me on Twitter.