Using serverless services to build REST or GraphQL APIs is becoming the architecture of choice for more and more developers. Startups, enterprises, and indie hackers are all using serverless technologies due to their ease of scaling, pay-per-use billing model, and rapid development cycle.
 
As you build your serverless APIs, you will need a security scheme to ensure your API is used by properly authenticated people or machines, and that these authenticated users can only perform the actions they are authorized to perform. In building serverless APIs on AWS, you have a few options for protecting your API, and your decision will depend on the needs of your application.
 
This article will review the best practices and options for securing your serverless APIs on AWS. First, we will review some background information about API security and about understanding your application needs. Then, we will look at the different types of authentication and high-level authorization you can use with serverless APIS on AWS, along with the pros and cons of each. Finally, we will review options and patterns for fine-grained authorization for your API.

API security best practices and considerations

Before we get into the specific API security best practices and options, let's examine some terminology and concepts around authentication.

Your security scheme is really composed of two parts: authentication and authorization. Each of these parts is important, and together they ensure the right person or entity is acting on your application data.

Authentication refers to verifying the identity of the person or thing calling your service. Essentially, you are making sure the caller of your API is who they say they are. If you are providing an API to human-based users, you might use the combination of a username and password to verify the identity of the user. If you are using a machine-based API, you might use an API token or a cryptographic signing mechanism to identify the principal calling your API.

But authentication isn't enough. Even if you have verified who a caller is, you often need to confirm that the principal calling your API is authorized to perform a requested action. Otherwise, any authenticated user would be able to check your bank balance or view your order history.

The second part of your authentication scheme is authorization. For authorization, your API is confirming that the verified user is allowed (or authorized) to perform the requested action. For write operations, you want to confirm that a user is allowed to create, delete, or alter records in their own workspace. For read operations, you want to confirm the data isn't restricted or, if it is, that the authenticated user has access to it.

Both elements of your authentication scheme are important, and you should think carefully about both parts when choosing and implementing authorization in your API.

Human-driven vs. machine-driven API authentication

Now that we know the components of an authentication scheme, we will look at the elements to consider in choosing the best authentication scheme for your API. First, consider who or what will be calling your API.

Some APIs will be used from a frontend, user-facing application. This can include a web browser for a social media application, a mobile app for your bank, or a video streaming service on your smart TV.

Because the interactions from these applications will be initiated by a human user, you will likely want to incorporate a human-friendly authentication scheme. This could mean a username and password mechanism that is native to your application, or it could mean allowing users to authenticate from another application using OIDC or other OAuth mechanisms.

On the other hand, it's possible that most interactions for your API are generated by machines. Perhaps you're a service that is used by other services in your company or is a B2B SaaS that is purchased for use in other applications. In this case, your API may be accessed as part of an automated, machine-to-machine process rather than direct user action. Accordingly, you may use an authentication method based on API keys that are treated as application secrets.

Complexity of API authorization permissions

Second, consider how finely grained your authorization permissions need to be. Some applications are simple, read-based applications that need only ensure that a caller is authenticated to allow authorization. More commonly, you'll need some authorization based on the position or team of the user.

Two common patterns here are role-based access control (RBAC) and attribute-based access control (ABAC). With role-based access control, an administrator will define a small number of roles that are assigned to users, such as Owner, Admin, and Member. After a user is authenticated, authorization is based on whether the authenticated user's role is allowed to perform a given action. Admins may be able to create new Users, while Members can only view existing users. The highest level, Owner, may be allowed to alter the roles of existing Admins.

Attribute-based access control is similar but with more fine-grained permissions. In ABAC, each user can be assigned attributes that describe the user, such as the team they're on, their role in the company, or their level of clearance. Resources can then be protected with various attribute requirements, so an authorization check would ensure the requesting user has the proper attributes to perform the desired actions.

Specialization of API authentication

Finally, consider how specialized your authentication needs are. Some of the options discussed below are fully managed options, which greatly reduce the operational needs around your authentication system and can help avoid costly security errors. However, you may have unique requirements for your application, such as fine-grained access control, integration with existing systems, or uniquely high availability needs. In those cases, you may need to design, build, and maintain your own authentication system.

In general, we recommend using a managed a service for your authentication needs. Proper authentication is a sensitive area, and the costs of a mistake can be dire. That said, don't compromise your application's core needs if they aren't satisfied by a managed service. Weigh your needs and decide accordingly.

Described below are a number of authentication options for serverless APIs on AWS. The option you choose should be based on the specific needs of your application.

Serverless API authentication methods and high-level authorization on AWS

Now that we understand some background on authentication, let's review the options you have for implementing API authentication and high-level API authorization in your serverless APIs on AWS.

When building serverless APIs on AWS, you can build on general-purpose tools like AWS API Gateway and AWS Lambda, or you can build fully managed GraphQL APIs on AWS AppSync. For a thorough look at how to choose between those options, review the article on Building Serverless APIs on AWS. Some of the authentication methods below can be used by either pattern or may be specific to a single pattern. We will discuss how each option applies to API Gateway and AppSync.

Note that this section focuses on authentication and high-level authorization. These are centralized mechanisms that are applied at the boundary of your application, before a request makes it to your business logic. Because of that, it's hard to apply fine-grained authorization down to specific resources. Such authorization often depends on details of the resource itself and is more entwined with your business logic. For fine-grained authorization mechanics, review the final section in this article.

Using AWS Identity and Access Management (AWS IAM) for API authentication

The first option for authentication on your serverless API on AWS is to use AWS IAM. This is the same scheme that is used when you interact with AWS services. You use IAM when you deploy your serverless API to a development account from your local machine, and your application uses IAM when it calls an AWS service from some AWS-managed compute like Lambda functions, Fargate containers, or EC2 instances.

AWS IAM provides both authentication and authorization. For authentication, you will cryptographically sign your requests to AWS services using a key pair that is tied to a specific identity. These can be long-lived credentials that are assigned from an admin in the AWS console, or these can be temporary credentials that are injected into your Lambda function or retrieved from an instance metadata service for your compute.

On the authorization side, IAM allows for both role-based and attribute-based access control. This provides flexible, scalable, and well-known authorization for those that are already in the AWS ecosystem.

Both API Gateway and AppSync support IAM-based authentication. If the client of your serverless API is another service in your larger application, IAM-based authentication is a safe, reliable way to secure your API. It is built for enormous scale and has high availability requirements. Further, it ties in with the tooling in the AWS ecosystem to make it easy to manage permissions and construct requests.

Yet for most APIs, your users won't have AWS IAM identities to use for authentication. Provisioning these for each user can prove challenging. Further, the credential signing process for AWS IAM is tricky. While the AWS SDK provides easy SigV4 signing for AWS services, you will need to manage the request signing process yourself when calling your own API. This is especially difficult in development scenarios where you want to use an HTTP client to test your APIs.

For these reasons, you may choose a different authentication option that is more friendly to end users.

Making use of Cognito User Pools for API authentication

Another type of authentication option for your serverless APIs is Cognito User Pools. Amazon Cognito is a fully managed service for handling users in your application. It will handle user authentication, including via direct username and password or through social sign-in like Amazon, Google, or Facebook.

With Cognito, the responsibility for handling user credentials is offloaded from you. Users will authenticate with the Cognito service directly and receive a JWT token back. This token can be sent to your serverless API to identify the calling user and implement more fine-grained authorization if needed.

Both API Gateway and AppSync allow you to protect your serverless APIs using Cognito User Pools. You can even assign your users to groups and include authorization based on users, allowing you to provide coarse-grained, role-based authorization to users.

If you are starting greenfield with a serverless API and don't have unique requirements, Cognito is an easy, robust way to get started. However, if your authentication needs are unique, you may need to choose a different solution.

Handling API authentication via OIDC

Cognito Users Pools are a nice solution for offloading your authentication to a managed service, but you may already have an existing managed authentication scheme for your application. If this authentication scheme is OIDC compliant, you may be able to use it to protect your serverless APIs.
 
Open ID Connect (OIDC) is an OAuth-based mechanism for authentication. It provides a mechanism for users to authenticate and receive an ID token back in the form of a JSON Web Token (JWT). It is actually the same mechanism that Cognito uses.
 
AppSync allows you to configure OIDC authorization for your application. API Gateway HTTP APIs also allow you to use OIDC authorization via JWT authorizations. However, the traditional API Gateway REST API service does not allow you to use OIDC authorization. Review the authorization documentation for REST APIs vs. HTTP APIs for more details.

Balancing flexibility and ease of use with Lambda authorizers

While Cognito and OIDC are great if your authentication needs fit their use cases, sometimes you have unique requirements that don't fit within the standard JWT workflow. You may need to integrate with existing authentication solutions, such as Active Directory or a custom database. Or, maybe you need to enrich your authenticated user with additional metadata that will be used in the business logic of your application. If so, a Lambda authorizer may be right for you.

A Lambda authorizer is a separate Lambda function that is called as part of the request to your API. When the request reaches your gateway, the gateway will first invoke your Lambda authorizer Lambda function with some context about the incoming request, such as the Authorization header. This function can perform any logic it needs to authenticate and authorize the request, and it will provide a response indicating whether the request should be allowed or denied. If it is allowed, the request will be forwarded to the appropriate business logic for the request. If it is denied, an error will be returned to the client.

With Lambda authorizers, you get the flexibility of an environment with a general-purpose programming language to handle your authentication logic while still getting some benefits of a managed service. You can cache the output of a Lambda authorizer, allowing you to reduce latency on subsequent requests.

The downsides of Lambda authorizers are the increased cost, latency, and maintenance burden of the Lambda function. For each non-cached request, you will need to pay for the Lambda function invocation and add the network latency to the Lambda service. Further, you are responsible for all code executed in your Lambda authorizer and will need to stay on top of library updates over time.

Lambda authorizers are available for both API Gateway APIs and AppSync APIs, so you can use them for any of your serverless APIs on AWS.

Opting for simplicity with API keys

If you have very simple authentication needs, you may not need a complex mechanism for protecting your API. Rather than relying on specific user identities via credentials, you can choose to use the API key authorization scheme to protect your API.

An API key is a simple string token that is included in the request to your API to allow access. This token does not include any identity about the client making the request. Both AppSync and API Gateway allow you to use API keys as a way to protect access to your API. Further, API Gateway allows you to control access to your API by adding usage plans with throttling to limit the number of requests to your API over a given time period.

Because API keys do not include identity, they should not be used for advanced authentication and authorization use cases. They are best used for simple, read-only use cases or for use in development mode.

Implementing multiple authorization methods in your AppSync API

The various authentication methods discussed above have different strengths and use cases. You can use the simple API key authentication for public-facing data, whereas Lambda authorizers, Cognito user pools, or OIDC authentication may work better for private data.

If you are using AWS AppSync for your serverless GraphQL API, you can combine multiple authentication methods on a single API. This allows you to customize the authorization for individual resolvers or fields in your GraphQL API.

To do so, you will need to specify an authorization mechanism on your API. This is the default authorization that will be applied to requests that come to your API. You can also register additional authorization types for your API.

Then, in your GraphQL schema, you can add annotation that indicate which authorization methods should apply to which fields.

For example, in a library application, we might have a GraphQL API like the following:

schema {
  query: Query
  mutation: Mutation
}

type Query {
  listBooks(num: Int, after: String): BookConnection
  getBook(title: String!): Book
}

type Mutation {
  addBookToLibrary(input: AddBookToLibraryInput): AddBookToLibraryResponse
  @aws_lambda
  checkBookOutToUser(input: CheckBookOutToUserInput): CheckBookOutToUserResponse
  @aws_lambda
}

In this schema, the default authorization method of API key will apply to public data patterns, such as listing books or retrieving an individual book.

However, for mutations like adding a new book to the library or checking out a book to a user, we will want to authorize that the request is valid. Adding a book will require the requesting user to be a librarian or other library admin, while checking out a book will require that the requesting user has a valid account at the library.

In the example above, we would block entire mutation operations to users that weren’t authenticated via our Lambda authorizer. If that’s too coarse for you, you can block individual fields on types in your GraphQL schema for required authorization modes.

For example, if you want to allow unauthenticated users to view books in the library, but you only want to show details about when a book was checked out or is due back, you can do so with the following schema:

type Book {
  title: String!
  authors: [String]!
  genre: String!
  publishDate: Int!
  status: String!
  dateCheckedOut: AWSDate @aws_lambda
  dateDueBack: AWSDate @aws_lambda
}

Most of the properties on the book will use the default API key authorization on the GraphQL schema. However, the dateCheckedOut and dateDueBack fields will require authorization via a Lambda authorizer and thus will only be visible to authorized users at the library.

By using multiple authorization methods on your AppSync GraphQL API, you can get the benefits of managed authorization while still maintaining the flexibility you need for your application.

Bearing the yoke by rolling your own API authentication

For the adventurous among us, you don’t have to rely on managed services for your applications. You can build your own authentication and authorization scheme yourself.

Each of the mechanisms discussed above handleauthentication at the gateway, before the request makes it to your business logic. By handling authentication this way, you can centralize your core authentication logic and make sure every request includes at least authentication and some minimum level of authorization.

If you roll your own authentication, you lose the benefits of this centralization. You’ll need to carefully check your GraphQL API and each resolver for proper authorization implementation. If you want to run your own GraphQL server inside AWS Lambda, rather than using a fully managed solution like AppSync, you will likely need to go this route to meet your authentication and authorization needs.

This option provides the most flexibility for you as a user. Additionally, you get similar flexibility to the custom authorizer option without the cost and latency of an additional Lambda invocation.

However, it's also putting the most burden on you and your team for authentication work. You'll need to manage user credentials in your application and the security burden that comes along with that. Further, you will be responsible for caching and other mechanisms for improving performance of the authorization check.

For most developers, this burden is not worth the flexibility. One of the more managed options above is a better fit for their application needs.

Fine-grained API authorization in your serverless APIs

In the previous section, we saw how to protect your API at a high level. You can use this to ensure any clients of your API are authenticated and even that they are a member of a group that is allowed to access a particular endpoint or resolver.

However, it is likely that your API has more restrictive authorization requirements. You probably want to limit which items a user is allowed to modify. With that, any user would be able to place orders, transfer money, or send messages for any account in the system. Thus, there are write-based restrictions you need to implement. Further, you may have read-based restrictions to implement. You want to restrict reading email messages to the owner of the inbox. Or, you may allow broad-based access to a particular resource but restrict certain fields depending on whether the authenticated user is the owner of the resource.

For these requirements, you will need to implement more fine-grained authorization. Because this authorization is application-specific, you will often need to implement it within your application's business logic. However, there are some helpers and patterns you can use to make this easier.

User identity and context in your business logic

Because you will need to implement most of the fine-grained authorization yourself in your business logic, your business logic will need some information about the authenticated principal calling your API for each request. You can then use this information to allow or restrict access to the requested data.

Most of the high-level authentication schemes discussed above will include details on the authenticated principal to your application's business logic. These details are included as the identity property on the context object in your business logic, and they can be used in the Lambda functions backing your API Gateway-based API, or the Lambda functions and AppSync functions backing your AppSync-based API. This identity object will include relevant details such as the username, Cognito User Pool Id, and the source IP address of the authenticated user.

For example, if you are using a Cognito user pool for your AppSync authentication, your resolver context will include information on the authenticated user, including the username (ctx.identity.username), UUID (ctx.identity.sub), and any assigned groups (ctx.identity.groups).

You could use these in an AppSync JavaScript resolver function as follows:

import { util } from '@aws-appsync/utils';
export function request(ctx) {
  return {
    operation: 'DeleteItem',
    key: util.dynamodb.toMapValues({ id: ctx.args.id }),
    condition: util.transform.toDynamoDBConditionExpression({
    owner: { eq: ctx.identity.sub },
  });
  };
}

In the example above, the JavaScript resolver is deleting the requested item. In doing so, it includes a condition to assert that the “owner” property on the record is equal to the Cognito UUID value from the authenticated user.

If you are using a Lambda authorizer for your high-level authentication, you can include any additional context that will be provided to your business logic. This context can include information like the organization to which a user belongs, the relevant groups or roles that apply to a user, or other relevant information.

To do so, include a resolverContext property in the response for your authorizer function to include it in the context of your business logic, as shown below.

export async function handler(event, context)
   // Custom logic to validate the given token and load information about the user.
   const user = await authenticateUserFromToken(event.authorizationToken)

  return {
    isAuthorizer: true,
    resolverContext: {
      username: user.username,
      team: user.teamId
      role: user.role,
    }
  }

By loading and sharing this context at the authorizer level, you prevent each element of your business logic from needing to fetch it.

Authorizer-level filtering in AWS AppSync

There are benefits to centralization of authorization logic wherever possible, as this reduces opportunities for error. Yet as we just saw, most of your fine-grained authorization will need to be located closer to your business logic.

We can still rely on centralized authorization logic in certain circumstances. There may be situations where certain fields in your GraphQL API are inaccessible to users without the proper permissions. Perhaps these are fields that are only accessible to administrators of your API to assist with debugging. Or, perhaps there are fields that are only visible to users with a certain role in a workspace, such as "owner".

You can use Lambda authorizers in AWS AppSync to assist with this. AppSync is a fully managed serverless GraphQL API service with real-time data synchronization and offline programming features. Just as you can include context in your authorizer response, you can also include a list of denied fields. Any fields that are included in the GraphQL response but that are specified as denied fields from the authorizer will be removed from the response before sending to the client.

Note that denied fields from Lambda authorizers are a fairly coarse-grained mechanism. You can only list fields that are denied altogether rather than fields that may be denied based on specifics about the requested data. For example, if a user is retrieving a list of employees in the company and is allowed to see personal information only if it is their own user record, you cannot filter the personal information fields entirely. You would need to implement this context-specific filtering in your business logic.

GraphQL subscription filtering in AWS AppSync

In the previous section, we saw how to filter sensitive data from clients on typical request-response calls to an API. GraphQL also includes a subscription feature that allows your GraphQL backend to push updates to clients when underlying data changes. This allows users of the GraphQL to build highly reactive applications. When using this subscription feature, we have similar requirements to filter data such that a client can only see the data for which they are authorized.

With AWS AppSync, you can add server-side filtering on your GraphQL subscriptions in order to protect data sent out in subscriptions. When registering a subscription for a connected client, subscription filters allow you to do exact match, greater than or less than, and similar comparisons on the context of a subscription message. You can filter on a specific user, a team, a chat room, or any other context to ensure that messages are sent to the proper parties.

API authorization helpers in AWS Amplify

Because centralization of fine-grained authorization logic is so difficult, AWS also provides tooling to make it easier to handle authorization in your business logic.

AWS Amplify is a toolkit for building full-stack web and mobile applications. It includes a number of components to make it easier to build and experiment quickly on AWS.

One of the areas where Amplify helps is in creating and developing a managed GraphQL API through AppSync. Not only can it help manage deployments, but it can also help with fine-grained authorization logic.

In creating your AppSync schema with Amplify, you can annotate your schema with authorization directives to protect your resources. For example, you can mark an entire type in your GraphQL schema as writable only by the owner of the resource while allowing read-based access to all authenticated users. Amplify will help generate the mapping templates for your AppSync API to handle the specified authorization.

Amplify provides a wide variety of helpers for authorization, including both owner-based and multi-user access along with field-level authorization rules. These can be complex to implement yourself, so using the helpers from Amplify can make this easier. Review the Amplify authorization rules documentation to understand the options available to you.

Conclusion

Proper authentication is a critical part of implementing your API to ensure you don't leak information to improper users or allow for undesirable alterations to customer data. In this article, we learned about how to implement authentication in your serverless APIs on AWS.

First, we looked at some background on authentication. We saw that authentication schemes have two components -- authentication and authorization -- and understood the difference between the two. Then we learned about considering the specific needs and requirements of your authentication scheme before implementing a solution.

After reviewing some background on authentication, we looked at options for implementing authentication for serverless APIs on AWS. We saw various options for high-level authentication and discussed the pros and cons of each approach. Finally, we reviewed patterns for implementing fine-grained authorization in your serverless API.

Looking for a fully managed GraphQL service?

Explore AWS AppSync

Explore AWS AppSync

AWS AppSync is an enterprise level, fully managed serverless GraphQL service with real-time data synchronization and offline programming features. AppSync makes it easy to build data driven mobile and web applications by securely handling all the application data management tasks such as real-time and offline data access, data synchronization, and data manipulation across multiple data sources.