API Security and Authentication Overview
Considering that not all Lambda functions should be public, different APIs in a system will require different levels of authentication and access.
We'll take a look at securing Lambda functions at API Gateway using IAM and Cognito authorizers, and setting up usage quotas with API keys.
Public APIs
Public APIs are endpoints that don't require the user to be authenticated first.
In this example, the GET
endpoint is public.
Authenticated APIs
Authenticated APIs are endpoints that require the user to be authenticated first.
These are generally API endpoints that may have functionality that updates the system state on a user's behalf:
- Updating a user Profile
- Place and managing orders
In this example, the POST
endpoint should be authenticated with Cognito using Cognito User Pool, or Cognito Federated Identities.
Internal / System APIs
Internal APIs are endpoints that will only be utilized by internal systems and microservices and are not consumed by the client app.
These are generally API endpoints that:
- Process data or files
- Manage push messaging to the client
- Handle email or SMS notifications
In this example, the Fanout
Lambda is only called internally and should be authenticated with IAM permissions.
Cognito User Pool and Cognito Federated Identities
AWS Cognito manages user sign-ups and authentication and also has the functionality to synchronize user profiles across devices.
Cognito User Pool
Cognito User Pool is a managed identity service that handles registration / registration verification / authentication and password policies.
During user authentication, Cognito provides temporary credentials to use to access other AWS resources or APIs in API Gateway.
New User Registration Flow
When a user registers and confirms their email, the client talks with Cognito User Pool:
- Client: Registers a new account.
- Cognito User Pool: Sends a verification token.
- Client: Confirms the verification token.
- Cognito User Pool: Completes the registration process.
Client Cognito User Pool API Gateway AWS Lambda
| | | |
| Register | | |
| New Account | | |
| -------------------► | | |
| | | |
| ◄------------------- | | |
| Send Verification | | |
| Token | | |
| (Email / SMS) | | |
| | | |
| | | |
| | | |
| | | |
| | | |
| Confirm | | |
| Verification | | |
| Token | | |
| -------------------► | | |
| | | |
| ◄------------------- | | |
| Registration | | |
| Complete | | |
| | | |
Authentication Sign In Flow
After a successful sign-in, Cognito User Pool returns a JWT token. This token needs to be passed in future HTTP headers for authentication in API Gateway.
- Client: Signs in with username and password.
-
Cognito User Pool:
- Authenticates the user with username and password.
- Returns an ID token with JWT.
- Client: Includes the JWT in the header of HTTP requests to API Gateway that are secured with the Cognito authorizer.
Client Cognito User Pool API Gateway AWS Lambda
| | | |
| Sign In | | |
| -------------------► | | |
| | | |
| ◄------------------- | | |
| ID Token | | |
| (JWT) | | |
| | | |
| | | |
| | | |
| | | |
| | | |
| HTTP POST { authorization: ... } | |
| ----------------------------------------► | |
| | | ------------------► |
| | | ◄------------------ |
| ◄---------------------------------------- | |
| | 200 {...} | |
Cognito Federated Identities
Cognito Federated Identities allows authentication with a supported identity provider (Google, Facebook, Twitter, etc).
The auth token issued by an auth provider is exchanged for temporary AWS IAM credentials, which can be used to access other AWS services.
- App / Client authenticates with a 3rd party identity provider
- The identity provider returns an auth token
- The auth token is sent to Cognito Federated Identities
- Cognito Federated Identities validates the auth token with the identity provider
- If the auth token is valid, Cognito will issue a temporary AWS IAM credential to the Client
- The client can now access other AWS services using the temporary AWS IAM credential
Identity Providers
┌────────────────────────────────────────────────────────┐
| |
| Google Cognito User Pool Facebook Twitter |
| |
└────────────────────────────────────────────────────────┘
▲ | Issues ▲
| | Auth Token |
| | |
| | |
Authenticate | | | Validate
(Login) | ▼ Send | Auth Token
┌─────────────────┐ Auth Token ┌────────────────┐
| | --------------------► | Cognito |
| App / Client | | Federated |
| | ◄-------------------- | Identities |
└─────────────────┘ Temporary IAM └────────────────┘
| Credential
| Use
| IAM Credential
|
▼ AWS Services
┌────────────────────────────────────────────────────────┐
| |
| API Gateway S3 DynamoDB SNS Kinesis ... |
| |
└────────────────────────────────────────────────────────┘
Securing a Lambda Function with Cognito
To require that the caller be authenticated with Cognito to invoke your Lambda Function, create the Cognito authorizer as CloudFormation resource, and set the authorizer for the lambda function to Cognito User Pool.
Note that we'll also have to add a new Cognito User Pool resource, CognitoUserPool
, and add the web and server clients.
Construct the CognitoAuthorizer
as a CloudFormation resource and then reference it in each function. The ProviderARNs
attribute for the CognitoAuthorizer
resource will point to the ARN of the CognitoUserPool
resource.
resources:
Resources:
CognitoAuthorizer: Type: AWS::ApiGateway::Authorizer
Properties:
AuthorizerResultTtlInSeconds: 300
IdentitySource: method.request.header.Authorization
Name: Cognito
RestApiId: !Ref ApiGatewayRestApi
Type: COGNITO_USER_POOLS
ProviderARNs:
- !GetAtt CognitoUserPool.Arn
functions:
search-stores:
handler: functions/search-stores.handler
events:
- http:
path: /stores/search
method: post
authorizer: type: COGNITO_USER_POOLS authorizerId: !Ref CognitoAuthorizer
resources:
Resources:
CognitoUserPool: Type: AWS::Cognito::UserPool Properties: AliasAttributes: - email UsernameConfiguration: CaseSensitive: false AutoVerifiedAttributes: - email Policies: PasswordPolicy: MinimumLength: 8 RequireLowercase: true RequireNumbers: true RequireUppercase: true RequireSymbols: true Schema: - AttributeDataType: String Mutable: true Name: given_name Required: true StringAttributeConstraints: MinLength: "1" - AttributeDataType: String Mutable: true Name: family_name Required: true StringAttributeConstraints: MinLength: "1" - AttributeDataType: String Mutable: true Name: email Required: true StringAttributeConstraints: MinLength: "1"
WebCognitoUserPoolClient: Type: AWS::Cognito::UserPoolClient Properties: ClientName: web UserPoolId: !Ref CognitoUserPool ExplicitAuthFlows: - ALLOW_USER_SRP_AUTH - ALLOW_REFRESH_TOKEN_AUTH PreventUserExistenceErrors: ENABLED
ServerCognitoUserPoolClient: Type: AWS::Cognito::UserPoolClient Properties: ClientName: server UserPoolId: !Ref CognitoUserPool ExplicitAuthFlows: - ALLOW_ADMIN_USER_PASSWORD_AUTH - ALLOW_REFRESH_TOKEN_AUTH PreventUserExistenceErrors: ENABLED
provider:
name: aws
runtime: nodejs12.x
functions:
search-stores:
handler: functions/search-stores.handler
events:
- http:
path: /stores/search
method: post
authorizer: type: COGNITO_USER_POOLS authorizerId: !Ref CognitoAuthorizer
resources:
Resources:
CognitoUserPool: Type: AWS::Cognito::UserPool Properties: AliasAttributes: - email UsernameConfiguration: CaseSensitive: false AutoVerifiedAttributes: - email Policies: PasswordPolicy: MinimumLength: 8 RequireLowercase: true RequireNumbers: true RequireUppercase: true RequireSymbols: true Schema: - AttributeDataType: String Mutable: true Name: given_name Required: true StringAttributeConstraints: MinLength: "1" - AttributeDataType: String Mutable: true Name: family_name Required: true StringAttributeConstraints: MinLength: "1" - AttributeDataType: String Mutable: true Name: email Required: true StringAttributeConstraints: MinLength: "1"
WebCognitoUserPoolClient: Type: AWS::Cognito::UserPoolClient Properties: ClientName: web UserPoolId: !Ref CognitoUserPool ExplicitAuthFlows: - ALLOW_USER_SRP_AUTH - ALLOW_REFRESH_TOKEN_AUTH PreventUserExistenceErrors: ENABLED
ServerCognitoUserPoolClient: Type: AWS::Cognito::UserPoolClient Properties: ClientName: server UserPoolId: !Ref CognitoUserPool ExplicitAuthFlows: - ALLOW_ADMIN_USER_PASSWORD_AUTH - ALLOW_REFRESH_TOKEN_AUTH PreventUserExistenceErrors: ENABLED
CognitoAuthorizer: Type: AWS::ApiGateway::Authorizer Properties: AuthorizerResultTtlInSeconds: 300 IdentitySource: method.request.header.Authorization Name: Cognito RestApiId: !Ref ApiGatewayRestApi Type: COGNITO_USER_POOLS ProviderARNs: - !GetAtt CognitoUserPool.Arn
Outputs:
CognitoUserPoolId:
Value: !Ref CognitoUserPool
CognitoUserPoolArn:
Value: !GetAtt CognitoUserPool.Arn
CognitoUserPoolWebClientId:
Value: !Ref WebCognitoUserPoolClient
CognitoUserPoolServerClientId:
Value: !Ref ServerCognitoUserPoolClient
plugins:
- serverless-pseudo-parameters
Securing a Lambda Function with IAM
To require that the caller submit IAM access keys to be authenticated to invoke your Lambda Function, set the authorizer to aws_iam
.
Generally, Lambdas that are only accessed by your infrastructure (and are not intended to be called by the client directly), should be restricted access by IAM role-based permissions
IAM authorization also makes sense as the caller will already be running within AWS and will already have an IAM role.
Manually signing with the aws4 NPM Package
In order to invoke a Lambda that is secured with an IAM authorizer, we'll need to sign and prepare our requests using AWS Signature Version 4.
Add the aws4 NPM package.
$ yarn add aws4
Add the execute-api:Invoke
to the IAM execution role in the iamRoleStatements
property:
Quick Note:
execute-api:invoke
permission allows calling API Gateway endpoints.lambda:InvokeFunction
permission allows directly invoking lambdas, bypassing API gateway.
provider:
name: aws
runtime: nodejs12.x
iamRoleStatements:
- Effect: Allow
Action: execute-api:Invoke Resource: arn:aws:execute-api:#{AWS::Region}:#{AWS::AccountId}:#{ApiGatewayRestApi}/${self:provider.stage}/GET/stores
In this example, we're going to have the get-index
function call the get-stores
function through API Gateway:
To require that the caller submit the IAM user's access keys to be authenticated to invoke your Lambda Function, use the aws_iam
authorizer for get-stores
endpoint.
We'll also need the URL of the /stores
API Gateway endpoint, so we're passing the URL in as an environment variable, stores_api
:
functions:
get-index:
handler: functions/get-index.handler
events:
- http:
path: /
method: get
environment:
stores_api: https://#{ApiGatewayRestApi}.execute-api.#{AWS::Region}.amazonaws.com/${self:provider.stage}/stores
get-stores:
handler: functions/get-stores.handler
events:
- http:
path: /stores
method: get
authorizer: aws_iam
The aws4
package prepares the opts
object with a headers
property using the URL of the get-stores
function.
We pass this authentication header into our HTTP request to the get-stores
URL:
const http = require("axios");
const aws4 = require("aws4");
const URL = require("url");
const storesApi = process.env.stores_api;
const getStores = async () => {
const url = URL.parse(storesApi);
const opts = { host: url.hostname, path: url.pathname, };
aws4.sign(opts);
const httpReq = http.get(storesApi, {
headers: opts.headers, });
return (await httpReq).data;
};
module.exports.handler = async (event, context) => {
const stores = await getStores();
const response = {
statusCode: 200,
body: stores,
};
return response;
};
We can test and see that the get-stores
endpoint is now secure by trying to hit the /stores
API endpoint directly:
$ curl -X GET https://xxxxxxxxxxxx.execute-api.us-east-1.amazonaws.com/dev/stores
{
message: "Missing Authentication Token"
}
Securing a Lambda Function with API Keys
We can also use API keys and Usage Plans to restrict a client's access on selected APIs to an agreed-upon request rate and quota.
It should be noted that API keys are designed for rate-limiting individual clients rather than for authentication and authorization.
Note: API key quotas apply to all APIs and Stages. The request rate and quota assigned to an API key apply to all the APIs AND the **stages covered by the current usage plan.**
In this example, we're going to have the get-index
function call the get-stores
function through API Gateway:
To require that the caller pass an API key to invoke your Lambda Function, set the private
boolean property to the http
event object for the get-stores
endpoint.
We'll also need the URL of the /stores API Gateway endpoint, so we're passing the URL in as an environment variable, stores_api:
functions:
get-index:
handler: functions/get-index.handler
events:
- http:
path: /
method: get
environment:
stores_api: https://#{ApiGatewayRestApi}.execute-api.#{AWS::Region}.amazonaws.com/${self:provider.stage}/stores
get-stores:
handler: functions/get-stores.handler
events:
- http:
path: /stores
method: get
private: true
We'll also need to create a Usage Plan to specify the rate limit and quota for the number of requests a client can make, and associate the API keys with a usage plan.
You can set up a general Usage Plan that all API keys will use, but I like to have specific Usage Plan categories and assign keys to each specific category.
In this example, the Usage Plan category names are:
free
paid
And we've generated two API key names.
freeKey
, assigned to thefree
Usage Plan category.paidKey
, assigned to thepaid
Usage Plan category.
In our implementation, we're specifying the API key names and allowing AWS to generate the actual keys for each API key.
provider:
name: aws
runtime: nodejs12.x
apiKeys:
- free:
- freeKey - paid:
- paidKey usagePlan:
- free: quota: limit: 5000 offset: 2 period: MONTH throttle: burstLimit: 200 rateLimit: 100 - paid: quota: limit: 50000 offset: 1 period: MONTH throttle: burstLimit: 2000 rateLimit: 1000
After you redeploy the stack, the API key name and values will be returned in the Serverless CLI output:
$ sls deploy
Serverless: Packaging service...
...
Serverless: Stack update finished...
Service Information
service: stores-service
stage: dev
region: us-east-1
stack: stores-service-dev
resources: 33
api keys:
freeKey: xxxxxxxxxxxxx paidKey: xxxxxxxxxxxxx
endpoints:
GET - https://xxxxxxxxx.execute-api.us-east-1.amazonaws.com/dev/
GET - https://xxxxxxxxx.execute-api.us-east-1.amazonaws.com/dev/stores
POST - https://xxxxxxxxx.execute-api.us-east-1.amazonaws.com/dev/stores/search
functions:
get-index: stores-service-dev-get-index
get-stores: stores-service-dev-get-stores
search-stores: stores-service-dev-search-stores
layers:
None
Now that the get-stores
Lambda has the private
boolean, API calls to get-stores
now need to pass an API key in the x-api-key
HTTP header of the request.
const http = require("axios");
const storesApi = process.env.stores_api;
const getStores = async () => {
const httpReq = http.get(storesApi, {
headers: {
"x-api-key": "xxxxxxxxx", },
});
return (await httpReq).data;
};
module.exports.handler = async (event, context) => {
let stores = await getStores();
// ...
const response = {
statusCode: 200,
body: stores,
};
return response;
};