DEV Community

Cover image for Serverless Authentication with AWS Lambda
Laura Carballo
Laura Carballo

Posted on

Serverless Authentication with AWS Lambda

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
Enter fullscreen mode Exit fullscreen mode

Add serverless offline plugin:

npm install --save-dev serverless-offline
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

authentication

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
Enter fullscreen mode Exit fullscreen mode

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,
  });
};

Enter fullscreen mode Exit fullscreen mode

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;
};

Enter fullscreen mode Exit fullscreen mode

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",
      }),
    };
  }
};

Enter fullscreen mode Exit fullscreen mode

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" }),
      };
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

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);
};

Enter fullscreen mode Exit fullscreen mode

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" }),
    };
  }
};

Enter fullscreen mode Exit fullscreen mode

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.

Get to know me better!
Twitter Github Website

Oldest comments (0)