• Simple AWS
  • Posts
  • Securing Microservices with AWS Cognito

Securing Microservices with AWS Cognito

Using Cognito for authentication and authorization to the Course Catalog microservice

Let's imagine the following scenario: we have an online learning platform that has been split into three microservices: Course Catalog, Content Delivery, and Progress Tracking. The Course Catalog microservice is responsible for maintaining the list of available courses and providing course details to users. To ensure that only authenticated users can browse the catalog, we need to implement a secure access mechanism for this microservice. We'll dive a bit into frontend code here, and I'll assume it's a React.js app.

We're going to use the following AWS services:

  • API Gateway: It's a managed service that makes it easy to create, publish, and manage APIs for your services. In another article I suggested we should use it to expose our microservices, but I'll start this article assuming you didn't do that.

  • Cognito: It’s a user management service that provides authentication, authorization, and user management capabilities. In our case, we use Cognito to manage user authentication and maintain their roles and permissions. Cognito lets us implement sign up and sign in, and when a user tries to access the course catalog, Cognito ensures that they are authenticated.

Secure access to Course Catalog microservice

How to Secure Microservices with AWS Cognito

Step 1: Set up a Cognito User Pool

  • Sign in to the AWS Management Console and open the Cognito console.

  • Click "Manage User Pools" and then "Create a user pool".

  • Enter a name for the new user pool, and choose "Review defaults".

  • In the "Attributes" section:

    • Choose standard attributes you want to collect and store for your users (e.g., email, name).

    • Set the minimum password length to 12 and require at least one uppercase letter and one number.

  • In the "MFA and verifications" section:

    • Set MFA to "Off" for simplicity, but consider enabling it for better security in the future.

    • Select "Email" as the attribute to be verified.

  • In the "App clients" section:

    • Click "Add an app client" to create a new application client that will interact with your user pool.

    • Enter a name for the app client, such as “SimpleAWSCourses”.

    • Uncheck "Generate client secret" as we won't need it for a web app.

    • Under "Allowed OAuth Flows," check "Authorization code grant" and "Implicit grant."

    • Under "Allowed OAuth Scopes," check "email," "openid," and "profile."

    • Set the "Callback URL(s)" to your the app's URL where users should be redirected after successful authentication (e.g., https://courses.simpleaws.dev/callback).

    • Set the "Sign out URL(s)" to the app's URL where users should be redirected after signing out (e.g., https://courses.simpleaws.dev/signout).

    • Save the app client and write down the "App client ID" for future reference.

  • In the "Policies" section:

    • Set "Allow users to sign themselves up" to enable user registration.

    • Set "Which attributes do you want to require for sign-up?" to require email and name.

    • Under "Which standard attributes do you want to allow for sign-in?", select "Email."

  • In the "Account recovery" section:

    • Set "Which methods do you want to allow for account recovery?" to "Email only."

  • Review all the settings and make any changes as needed. When finished, click "Create pool."

  • Create a groups within the User Pool, called "User". Click "Groups" in the left-hand navigation pane, and then click "Create group".

  • Select the "User" group and check the "Set as default" checkbox. This will automatically add new users to the "User" group when they sign up.

Step 2: Create an API in API Gateway

  • Open the Amazon API Gateway console in the AWS Management Console.

  • Click "Create API" and select "REST API." Then click "Build."

  • Choose "New API" under "Create new API," provide a name for your API (e.g., "SimpleAWSCourseCatalogAPI"), and add an optional description. Click "Create API."

  • Click "Actions" and select "Create Resource." Provide a resource name (e.g., "CourseCatalog") and a resource path (e.g., "/courses"). Click "Create Resource."

  • With the new resource selected, click "Actions" and choose "Create Method." Select "GET" from the dropdown menu that appears. This will create a GET method for the resource.

  • For the GET method's integration type, choose "AWS Service." Select "ECS" as the AWS Service, choose the region where the app is deployed, and provide the "ARN" of the ECS service. Set the "Action Type" to "HTTP Proxy" and provide the "HTTP Method" as "GET."

Step 3: Set up Cognito as the API Gateway authorizer

  • In API Gateway, click "Authorizers" in the left-hand navigation pane, and then click "Create New Authorizer".

  • Select "Cognito" as the type and choose your previously created Cognito User Pool.

  • Enter a name for the authorizer.

  • Set the "Token Source" as "Authorization" and click "Create".

Step 4: Attach the Cognito authorizer to the API methods

  • Click on the method in the API Gateway console.

  • In the "Method Request" section, click on "Authorization" and select the Cognito authorizer you created in step 3 from the "Authorization" dropdown menu.

  • Save the changes.

Step 5: Deploy the API

  • In the API Gateway console, click "Actions" and then "Deploy API".

  • Choose or create a new stage for deployment.

  • Note the Invoke URL provided for each method.

Step 6: Update the frontend

  • Remember that we're using React.js for this example.

  • First, you'll need to install these dependencies: npm install amazon-cognito-identity-js aws-sdk

  • Then you'll need a file with your configs, and to update your app. Here's an example of the config in config.js file and a very basic App.js file (with no CSS, and probably missing a few React good practices).

const config = {
  region: "your_aws_region",
  cognito: {
    userPoolId: "your_cognito_user_pool_id",
    appClientId: "your_cognito_app_client_id",
  },
  apiGateway: {
    apiUrl: "your_api_gateway_url",
  },
};

export default config;
import React, { useState } from "react";
import {
  CognitoUserPool,
  CognitoUser,
  AuthenticationDetails,
} from "amazon-cognito-identity-js";
import AWS from "aws-sdk";
import config from "./config";

const userPool = new CognitoUserPool({
  UserPoolId: config.cognito.userPoolId,
  ClientId: config.cognito.appClientId,
});

const authenticateUser = async (username, password) => {
  const user = new CognitoUser({ Username: username, Pool: userPool });
  const authDetails = new AuthenticationDetails({
    Username: username,
    Password: password,
  });

  return new Promise((resolve, reject) => {
    user.authenticateUser(authDetails, {
      onSuccess: (result) => {
        const accessToken = result.getAccessToken().getJwtToken();
        const idToken = result.getIdToken().getJwtToken();
        resolve({ accessToken, idToken });
      },
      onFailure: (err) => {
        reject(err);
      },
    });
  });
};

const callApi = async (method, path, accessToken) => {
  const headers = {
    Authorization: `Bearer ${accessToken}`,
  };

  const options = {
    method,
    headers,
  };

  const response = await fetch(`${config.apiGateway.apiUrl}${path}`, options);
  const data = await response.json();

  if (!response.ok) {
    throw new Error(data.message || "Error calling API");
  }

  return data;
};

function App() {
  const [username, setUsername] = useState("");
  const [password, setPassword] = useState("");

  const handleLogin = async () => {
    try {
      const { accessToken } = await authenticateUser(username, password);
      const courses = await callApi("GET", "/courses", accessToken);
      console.log(courses);
    } catch (error) {
      console.error("Error:", error.message);
    }
  };

  return (
    //Your awesome React code that's much better than mine.
  );
}

export default App;

Solution explanation

  • Set up a Cognito User Pool

    Cognito User Pools store and manage user profiles, and handle registration, authentication, and account recovery. We want to offload all that to Cognito, and we also want to use it to authorize users. Our authorization logic is really simple: if they're a user, they get access. For that, we set up our “User” user pool as the default, so all registered users are in that group. We could set up more user pools for different roles, for example “Admin” for administrators, or “Paying Users” for users in a paid plan.

  • Create an API in API Gateway

    API Gateway is the central component that manages and exposes your microservice to the frontend. The "CourseCatalog" resource and the "GET" method exposes our Course Catalog microservice's functionality, which is integrated with the HTTP Proxy method. Basically API Gateway receives the requests, runs the authorizer (which we set up in the next step), runs anything else that needs running (nothing in this case, but we could set up WAF, Shield, transform some headers, etc), and then passes the request to our ECS service. ECS handles the request, returns a response, API Gateway can do a few things with the response such as transform headers, and returns the response. We're using API Gateway here to separate the endpoint from the service, which allows us to replace the service with something else entirely without changing the endpoint or how it's invoked. I argue that you should put API Gateway in front of everything.

  • Set up Cognito as the API Gateway authorizer

    This step ensures that only authenticated and authorized users can access our Course Catalog. By using the Cognito User Pool as the authorizer, we're basically saying “if the user is in this Cognito User Pool, let the request pass”. You can also use a custom Lambda Authorizer, with a Lambda function that can do anything you want, such as calling an external service like Okta, checking a table in DynamoDB, or any complex logic. Using the Cognito User Pool as an authorizer instead of a custom Lambda Authorizer is a new-ish feature that makes it easier to implement this simple logic, a couple of years ago in order to do this you needed a Lambda that called the Cognito API.

  • Attach the Cognito authorizer to the API methods

    There's two steps to setting up the Cognito authorizer: Creating it (previous step) and attaching it to all relevant endpoints (this step). In our case we're only dealing with one endpoint (GET) in one microservice (CourseCatalog). If you want to secure the whole app, this is where you attach that one authorizer to all endpoints. Don't create more of the same authorizers, you create one and reuse it for all endpoints. Feel free to create different ones though, for example if you wanted to secure the POST endpoint so only “Admin” users can access it, you need another Cognito User Group and another Authorizer that checks against that group.

  • Deploy the API

    All set, now let's take it for a spin. Deploy it and test it!
    By the way, if you make changes to API Gateway, you need to deploy them before testing it. I've wasted half an hour several times because I forgot to deploy the changes.

  • Update the frontend
    I hope I didn't go too overboard with all the frontend code. The thing is we're adding authentication to the whole app, and that includes adding a login button and a login form. Most importantly though, we need to understand how to talk to Cognito to authenticate a user, and what to pass to our CourseCatalog endpoint so the authorizer recognizes the user as a logged in, valid user. That's what I wanted to show. If you write your frontend, make it prettier than my bare-bones example. If you have frontend devs in your team, show them that code so they know what behavior to add, and ask them to subscribe to the newsletter!

Discussion

Since we applied security at the API Gateway level, we've decoupled authentication and authorization from our microservice. This means we can use the exact same mechanism for the other two microservices: Content Delivery and Progress Tracking.

It also means we've offloaded the responsibility of authorizing users to API Gateway. That way our microservices remain focused on their actual task (which is important for services, makes them much easier to maintain), and they also remain within their bounded context instead of having to dip into the shared context of application users (which is important for microservices, because the bounded context is the key to them, otherwise we'd be better off with regular services).

There's one caveat to our auth solution: the Content Delivery microservice returns the URL to an S3 object, and (as things are right now), that object needs to be public. That means only an authenticated user (i.e. paying customer) can get the URL, but once they have it they're free to share it with anyone. Securing access to content served through S3 is going to be the topic of next week's issue.

One more thing about Cognito: If your app users needed AWS permissions, for example to write to an S3 bucket or read from a DynamoDB table, you'd need to set up an Identity Pool that's connected to your User Pool. We can dive into that in a future issue.

There's a security problem with this solution. The flow went like this: User → API Gateway (with Cognito auth) → ALB → ECS. The problem is that the ALB is public, so any malicious actor could skip Cognito by just hitting the LB directly. Here's how to fix it:

  • Making the ALB private and using a VPC Link for API Gateway, and setting up the ALB security group to only accept traffic from the VPC Link security group. This way, no actors outside the VPC can route traffic to the ALB directly, and actors inside the VPC can only do so if they have the correct security group.

  • Having API Gateway add a header with a static “password” and having the ALB's listener only route requests that contain that same “password”. This way, even a malicious actor with access to the VPC (e.g. a compromised instance) and with the correct security group can't send traffic to the microservice. They could run a DoS attack against the LB (which is why we need the other measure as well), but it will process and drop requests that don't have that “password”, instead of forwarding them to the microservice. This is a great example of zero-trust architecture: just because it's in our VPC doesn't mean we trust it.

Best Practices

Operational Excellence

  • Use Infrastructure as Code: That was a lot of clicks! It would be a lot easier if you had all of this in a CloudFormation template, and you reused it for every project that requires authentication (which is 99.99999% of them).

  • Implement monitoring and alerting: Set up dashboards and alarms in CloudWatch to monitor the health and performance of your APIs, microservices and other components. A good alarm would be a % of responses being 4XX or 5XX (i.e. errors!).

Security

  • Enable MFA in Cognito User Pool: You can offer your users the option of adding MFA to their login. You know how important this is, you've already set it up for your root user and IAM users when you read the 7 Must-Do Security Best Practices issue. At least do it for the Admins.

  • Configure AWS WAF with API Gateway: Add an AWS Web Application Firewall (WAF) to your API to protect it from common web attacks like SQL injection and cross-site scripting. We already talked about it when we discussed securing endpoints.

  • Enable AWS Shield: Shield helps protect your API from Distributed Denial of Service (DDoS) attacks. Like WAF, you also enable it in API Gateway.

  • Encrypt data in transit: tl;dr: use HTTPS. You can get a free, auto-renewing certificate from Amazon Certificate Manager, and you install it in the Application Load Balancer.

Reliability

  • Offer a degraded response: Your microservice can fail, for whatever reason (internal to the service, a failure in DynamoDB, etc). Caching helps, but you should also consider a degraded response such as a static course catalog served from a different place, like S3. It's not up to date, sure, but it's usually better than a big and useless error message.

  • Consider Disaster Recovery: In AWS jargon, high availability means an app can withstand the failure of an Availability Zone, and disaster recovery means it can withstand the failure of an entire AWS region. There's different strategies to this, but the most basic one called Pilot Light involves replicating all the data to another region, deploying all the configurations, deploying all the infrastructure with capacity set to 0, and configuring Route 53 to send traffic to that region if our primary region fails. We're going to talk about disaster recovery in a future issue, for now just keep it in mind, and think whether you really need it (most apps don't).

Performance Efficiency

  • Use caching in API Gateway: Enable caching in API Gateway to reduce the load on your backend services and improve the response times for your API. You can probably set a relatively long TTL for this, maybe 1 hour. I mean, how often does your course catalog change?

Cost Optimization

  • Remember API Gateway usage plans: This one's actually not relevant to this particular solution, but I felt it was a good opportunity to throw it in here. You can configure usage plans and API keys for your APIs in API Gateway, and limit the number of requests made by your users. This helps to control costs and prevents abuse of your API. Not what you want for the public API of CourseCatalog, but it's important and useful for private APIs.

Resources

I'm going back to viewing Adrian Cantrill's courses, specifically the one for the Security Specialty cert. I'll let you know when I schedule the exam!

There's a tool called K8SGPT, and it gives you Kubernetes SRE Superpowers! Need I say more?

Have you read Site Reliability Engineering? Fantastic lessons on managing infrastructure at Google, which we'll never copy verbatim since we don't manage anything at Google's level, but they're really useful for designing better systems at any scale. It's free, BTW.

Did you like this issue?

Login or Subscribe to participate in polls.

Reply

or to participate.