When it comes to authentication it is highly recommended to use a third party service. There are multiple options like Auth0 or Magic.link that provide an easy to integrate authentication that allows us developers to not worry about security issues.
But, for those getting a bit curious about how authentication really works in serverless applications, I have come up with this easy tutorial that will guide you through the process.
We’ll be using serverless functions to configure our backend. I also like using the serverless framework, since it simplifies a lot the process of developing and deploying serverless functions, while the serverless offline plugin emulates AWS Lambda and API Gateway on your local machine.
With all of that said, let’s start!
Setup
As I’ve said, the serverless framework makes it easy to set up our serverless environment.
Just run these commands in your project's folder:
Install serverless CLI globally:
npm install -g serverless
Add serverless offline plugin:
npm install --save-dev serverless-offline
Make sure to set up your AWS credentials and run serverless
to verify that everything is working properly.
We can now install our dependencies: jsonwebtoken
, bcrypt
and the cookie
package.
npm install jsonwebtoken bcrypt cookie
serverless.yml
Briefly, the serverless.yml
serves as a schema for our serverless application. It will configure our functions and create our users database in DynamoDB. We'll be configuring something like this:
Looking to our basic schema, we will be needing three lambda functions:
- /signup and /login: handle
POST
requests with the user’s information from the login and sign up form. - /profile: handles
GET
request of our user’s profile retrieved from the database.
Regarding our database, to keep it simple we’ll create a single-table DynamoDB database with a partition key (HASH key) of userId
and a sortKey
(RANGE) which we’ll be our user’s profile. We won't be using the sortKey
now but it will help us access data in the future once we start scaling our application.
service: users
frameworkVersion: "2"
provider:
name: aws
runtime: nodejs12.x
environment:
USERS_TABLE: { Ref: usersTable }
iamRoleStatements:
- Effect: Allow
Action:
- dynamodb:PutItem
- dynamodb:GetItem
Resource: { Fn::GetAtt: [usersTable, Arn] }
functions:
signup:
handler: handler.signup
events:
- http:
path: signup
method: post
cors: true
login:
handler: handler.login
events:
- http:
path: login
method: post
cors: true
profile:
handler: handler.profile
events:
- http:
path: profile
method: get
cors: true
resources:
Resources:
usersTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: "users"
AttributeDefinitions:
- AttributeName: userId
AttributeType: S
- AttributeName: sortKey
AttributeType: S
KeySchema:
- AttributeName: userId
KeyType: HASH
- AttributeName: sortKey
KeyType: RANGE
plugins:
- serverless-offline
If we run serverless deploy
in our command line, serverless will deploy our functions and for now, it will create our database inside DynamoDB.
Creating JWT and generating a Cookie
Before we start building our functions, we need to ensure that our users are able to stay logged in while they browse through our application. To do so, we’ll create a JSON Web Token containing our userId
and we’ll store it inside a cookie in the users browser, these tokens can then be verified and decoded allowing the user to browse the private routes from our application.
Important: Don’t forget to add your JWT secret and store it safely inside your .env
file. It is important that you save this key in a safe place since it is what keeps your application secure and prevents unauthenticated users from impersonating others and accessing your private routes.
Also note that in production cookies should have a httpOnly
attribute to prevent XSS, a Secure
attribute to guarantee cookies are only transferred via HTTPS, and a SameSite=Strict
attribute to prevent CSRF.
const jwt = require("jsonwebtoken");
const cookie = require("cookie");
const DAY = 24 * 60 * 60; // 1 day in seconds
const { JWT_SECRET } = process.env;
module.exports.generateCookie = (userId, expireTimeInDays) => {
const token = jwt.sign({ userId }, JWT_SECRET, {
expiresIn: expireTimeInDays + "d",
});
return cookie.serialize("token", token, {
maxAge: expireTimeInDays * DAYS,
httpOnly: true,
});
};
Also, we should never store our user’s password in plain text inside our database so we’ll use bcrypt
to hash it. We'll use bcrypt.hash
to hash our user's password before we store it in our database and later use bcrypt.compare
to check if the password the user provided when logging in is matching the hash.
const bcrypt = require("bcrypt");
const ITERATIONS = 12;
module.exports.hashPassword = async (password) => {
const hash = await bcrypt.hash(password, ITERATIONS);
return hash;
};
module.exports.matchPassword = async (password, hash) => {
const match = await bcrypt.compare(password, hash);
return match;
};
Setting up our handler functions
Let’s start with the signup handler. We will first parse the request body containing our user’s data, hash the password calling the hashPassword
function and add the user’s inputs and hashed password inside DynamoDB.
Once that’s done, we’ll create a cookie that will be sent inside the response headers to the browser.
Also, we'll be using the user's email as our userId
.
module.exports.signup = async (event) => {
const { name, email, password } = JSON.parse(event.body);
if (name && email && password) {
const userId = email;
const hash = await hashPassword(password);
await dynamoDb
.put({
TableName: "users",
Item: {
userId: email,
sortKey: "profile",
name: name,
password: hash,
},
})
.promise();
const cookie = generateCookie(userId, 1);
return {
statusCode: 200,
headers: {
"Set-Cookie": cookie,
},
body: JSON.stringify({ success: true }),
};
} else {
return {
statusCode: 401,
body: JSON.stringify({
success: false,
error: "Enter a valid name/email/password",
}),
};
}
};
Our login handler will check if the user's email exists in the database. We then check if the user’s password matches the hashed password in the database and create and send a cookie inside the response headers.
module.exports.login = async (event) => {
const { email, password } = JSON.parse(event.body);
if (email && password) {
const { Item } = await dynamoDb
.get({
TableName: "users",
Key: { userId: email, sortKey: "profile" },
})
.promise();
if (!Item) {
return {
statusCode: 404,
body: JSON.stringify({ success: false, err: "user not found" }),
};
}
const { userId, password: hashedPassword } = Item;
const matchedPassword = await matchPassword(password, hashedPassword);
if (matchedPassword) {
const cookie = generateCookie(userId, 1);
return {
statusCode: 200,
headers: {
"Set-Cookie": cookie,
},
body: JSON.stringify({ success: true }),
};
} else {
return {
statusCode: 401,
body: JSON.stringify({ success: false, err: "incorrect password" }),
};
}
}
};
Finally the profile handler, which is a protected route, gets the cookie from the headers, we parse it with the help of the cookie
package and verify if the JSON Web Token is still valid and hasn't expired, if so, the user is authenticated.
module.exports.verifyCookie = (cookieHeader) => {
const { token } = cookie.parse(cookieHeader);
return jwt.verify(token, JWT_SECRET);
};
If the verifyCookie
function is successful, it will return the userId
from the token and we can use that to get the user's data from the database.
module.exports.profile = async (event) => {
const cookieHeader = event.headers.Cookie;
try {
const decoded = verifyCookie(cookieHeader);
const data = await dynamoDb
.get({
TableName: "users",
Key: { userId: decoded.userId, sortKey: "profile" },
})
.promise();
return {
statusCode: 200,
body: JSON.stringify({ success: true, name: data.Item.name }),
};
} catch (error) {
return {
statusCode: 401,
body: JSON.stringify({ success: false, err: "not authorized" }),
};
}
};
Conclusion
That's it for now! I hope you found this post useful and it gave you an idea of how authentication in a serverless environment could work.
Here's also a link to the Github repo
Please let me know your thoughts in the comments below.
Top comments (0)