How to use Lambda authorization with AppSync

Configure a Lambda function to decide whether a request is allowed or not

Author's image
Tamás Sallai
6 mins

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 or doc2

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.

April 5, 2022
In this article