How to use Lambda authorization with AppSync
Configure a Lambda function to decide whether a request is allowed or not
Authorization in AppSync
AppSync supports several ways for authorization, such as Cognito, AWS IAM, API key, and a custom Lambda function. The last one allows you to define arbitrary access control scheme for the API as a Lambda can run any code, fetch data from any database, and interact with third-party systems. Combined with the ability to parse the GraphQL query, this provides a universal solution when the built-in ones are not sufficient.
In this article, we'll look into how to add a Lambda function as an authorizer to an API, how to handle requests, and the most common use-cases for authorization.
Lambda authorization
First, configure a function for the API:
When a request arrives to AppSync, it calls the function with the Authorization token as well as information about the query:
{
"authorizationToken": "token2",
"requestContext": {
"apiId": "q6ggwm5zqfgszjtnz4iatx7sam",
"accountId": "278868411450",
"requestId": "82035a43-bb90-4cf4-b498-d50bb74b663e",
"queryString": "query MyQuery {\n document(id: \"doc2\") {\n id\n title\n }\n}\n",
"operationName": "MyQuery",
"variables": {}
}
}
The central part is the authorizationToken
. This is the value the client sends in the authorization
header:
The Lambda then needs to return an object with at least an isAuthorized: true/false
property.
Example data model
In this article we'll implement a simple token-based system that allows access to various parts of the data model. The system has Documents
and
Files
, and tokens can grant fine-grained access to these types.
The schema we'll use:
type Document {
id: ID!
title: String!
text: String!
}
type File {
id: ID!
name: String!
url: String!
}
type Query {
document(id: ID!): Document
file(id: ID!): File
}
schema {
query: Query
}
The tokens are stored in a DynamoDB table and they can:
- Deny access to fields, such as
Document.text
- Deny certain queries, such as
file
- Restrict the accessible documents, such as
doc1
ordoc2
To implement this, we'll use various features of AppSync.
Retrieving tokens from the database
First, the function needs to fetch the token and deny access if it is not found:
const client = new DynamoDBClient();
const item = await client.send(new GetItemCommand({
TableName: tokens_table,
Key: {id: {S: token}},
}));
if (!item.Item) {
return {
isAuthorized: false,
};
}else {
// ...
}
The isAuthorized: false
means AppSync will send an unauthorized error to the client.
Deny fields
Next, AppSync provides a built-in way to return an error when certain fields are accessed. This provides an easy way to control access to fields based on a dynamic value (such as a database field).
To deny access to fields, use the deniedFields
property in the result in the form of TypeName.FieldName
.
In our example, some tokens deny access to the Document.text
field:
Since the database entry is in the expected format, just return the value:
return {
isAuthorized: true,
deniedFields: item.Item.denied_fields.SS,
// ...
};
With this, a request with token1
can not query the document text:
query MyQuery {
document(id: "doc1") {
id
title
text
}
}
But the same query with token2
works:
{
"data": {
"document": {
"id": "doc1",
"title": "Document 1",
"text": "Text for document 1"
}
}
}
Resolver-based restrictions
Let's move on and see how to restrict access to some documents!
This is a trickier problem, as it's not a field-level decision but needs to take the arguments into account. Since the Lambda function gets the whole GraphQL
query and the variables, theoretically it could parse it and return an isAuthorized
based on the database value. But that's a more involved solution, and
it also has a few potential problems as we'll see in the TTL chapter.
Instead, we'll implement a resolver-based access control scheme where the Lambda provides information to the resolver and that resolver denies the request instead of the function.
On the Lambda's side, the result object supports the resolverContext
field which is available under the $ctx.identity.resolverContext
in the
resolvers. The gotcha here is that while the resolverContext
is an object, its fields must be strings, so the function should stringify any complex data
structure. In code, this is the JSON.stringify
call.
return {
isAuthorized: true,
resolverContext: {
documents: JSON.stringify(item.Item.documents.SS),
},
// ...
};
On the resolvers' side, the documents
can be parsed to a list:
#if(
!$util.parseJson($ctx.identity.resolverContext.documents).contains($ctx.arguments.id)
)
$util.unauthorized()
#end
With this structure, the custom authorizer can provide any data to the resolvers.
Deny queries
The deniedFields
provides an easy way to access control to fields, but it has a limitation: it does not work for top-level types (fields of Query
or Mutation
).
To implement this missing functionality, the easiest way is to use the resolverContext
again and implement access control in the resolvers.
First, return the allowed list of top-level fields:
return {
isAuthorized: true,
resolverContext: {
documents: JSON.stringify(item.Item.documents.SS),
allowedQueries: JSON.stringify(item.Item.allowed_queries.SS),
},
// ...
};
Then add a check to the resolver:
#if(
!$util.parseJson($ctx.identity.resolverContext.allowedQueries).contains("document") ||
!$util.parseJson($ctx.identity.resolverContext.documents).contains($ctx.arguments.id)
)
$util.unauthorized()
#end
Similarly, add the check for the resolvers of other top-level fields:
#if(!$util.parseJson($ctx.identity.resolverContext.allowedQueries).contains("file"))
$util.unauthorized()
#end
TTL
Without any optimizations, the authorizer Lambda runs for every single Query/Mutation/Subscription that hits the API. While Lambda is supposed to scale, this generates a ton of invocations and that can get expensive quickly. Because of this, AppSync has a few optimizations for the authorization function.
It caches the authorizer's response for the authentication token for a configurable amount of time. By default, this is 5 minutes (300 seconds), so if the same user is making repeated calls within this window only the first one will go to the authorization Lambda.
You can configure it on the API-level using the Authorizer Response Cache TTL
setting, and the function can also return a ttlOverride
that is
effective only for that single response:
return {
isAuthorized: true,
// ...
ttlOverride: 0,
};
Note that caching is based on the auth token, but the decision might use other things, such as the query, its variables, or external systems. In our example the token is validated against a database, so if you delete the value from the table the user is still allowed up to the cache TTL.
A bigger problem is when the authorization depends on the GraphQL query while it's missing from the cache key. In this case, users can break the system by sending an allowed query first and then send one that should be denied. Since AppSync caches the decision for the first query, the Lambda does not run for the second, giving access to protected resources.
Because of this, either always return a constant response for a token (like we did in the examples above) or make sure to set the TTL to 0 to force reevaluation.