Using Cognito user ID to set up item-level access control to tables - Authenticated users in Cognito Identity pools, Part 3

Part 1 of the series discussed IAM roles in Cognito and the access to DynamoDB tables based on user groups. In Part 2, I presented a way to control access to individual items and attributes in DynamoDB using user groups and principal tags. This blog will merge the findings from the previous posts and show an easier way to implement item-level access control in the table.

1. OK, but why again?

Let’s quickly recap the scenario first. Every authenticated user (i.e., users who sign in to the application with their username and password) can only retrieve items relevant to them from the table. If we assign only items A and B to User1, that user might not view C or any other items.

I demonstrated a working solution through a simple but very realistic and in-demand example with a secret agent application where office administrators can only retrieve agents’ data whom they manage.

The last post showed a solution based on tags and attribute-based access control. We can map the user’s user pool ID to a principal tag, which becomes a session tag when Identity pools assumes the assigned group’s role.

But AWS recommends using the sub claim in the role’s permissions policy, which is a different - and probably easier - way to achieve the same goal. Since I found some history of questions and confusion about the documentation, I decided to follow up on this issue and do my best to make it work.

The resulting solution involves some more code but is less complicated than described in the last post.

2. Pre-requisites

I made the previous two articles and this one a series because their concepts are closely related. It’s probably a good idea to read Part 1 and Part 2 first.

Although I’ll repeat some concepts in this post, you’ll find more detailed explanations in the other articles.

3. Cognito user ID

We want the following statement in the permissions policy as per the official solution:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Condition": {
        "ForAllValues:StringEquals": {
          "dynamodb:LeadingKeys": [
            "${cognito-identity.amazonaws.com:sub}"
          ]
        }
      },
      "Action": [
        "dynamodb:Query"
      ],
      "Resource": "arn:aws:dynamodb:eu-central-1:123456789012:table/AgentsTable",
      "Effect": "Allow",
    }
  ]
}

AWS recommends using the sub claim to identify the authenticated user. Thus, we should use the sub value as a partition key in the table. This way, users can only retrieve data where the partition key is the same as their Cognito user ID.

3.1. About sub

When the user signs in, Cognito User pools will issue an ID token, which is a JSON Web Token (JWT). sub is one of the claims in the token and its value is the user pool ID of the user. It seems intuitive that the sub in the policy variable is the same as the user pool ID sub.

But it’s NOT, and this is where many developers - including myself - went wrong. Instead, it refers to the Cognito identity pool user ID.

I found the documentation’s wording confusing. It mentions Amazon Cognito user ID at one point and Amazon Cognito ID at another. It’s not straightforward to me which ID I’m supposed to use. Although the ${cognito-identity.amazonaws.com:sub} policy variable gives us a hint (cognito-identity for Identity pools vs. cognito-idp for User pools), it’s easy to overlook. At least, it was easy for me.

3.2. How can we get the identity pool user ID?

An authenticated user has both a user pool and - if used - an identity pool ID. We’ll need the identity pool user ID.

As discussed in Part 1, we should make two API calls to get credentials to AWS services via Identity pools. These are GetId and GetCredentialsForIdentity. The fromCognitoIdentityPools credentials provider method also calls these APIs.

For example, we can make calls to DynamoDB after we have configured the client like this:

const ddbClient = new DynamoDBClient({
  region: 'eu-central-1',
  credentials: fromCognitoIdentityPool({
    clientConfig: { region: 'eu-central-1' },
    identityPoolId: 'IDENTITY_POOL_ID',
    logins: {
      'cognito-idp.eu-central-1.amazonaws.com/USER_POOL_ID':
        'ID_TOKEN',
    },
  }),
});

ID_TOKEN here is the ID token that the user pool returns after the user has signed in. That token doesn’t contain the identity pool user ID.

But we can retrieve it from the GetId API. The response will contain the IdentityId that is the same identity pool user ID we need here (see below).

3.3. Behind the scenes - the Identity pools ID token

It’s great, but where will IAM get the sub’s value from? The ${cognito-identity.amazonaws.com:sub} policy variable refers to it, so there must be something somewhere that contains a sub property.

Cognito Identity pools can also return an ID token. We don’t see this in the example code because everything happens in the background, but we can catch it by making an API call.

We can get the identity pool’s ID token from the GetOpenIdToken endpoint. We can call it with the IdentityId (i.e., the identity pool user ID, which is what we need in this solution) and - for authenticated users - the Logins object:

{
  "IdentityId": "IDENTITY_ID",
  "Logins":{
      "cognito-idp.eu-central-1.amazonaws.com/USER_POOL_ID": "USER_POOL_ID_TOKEN"
  }
}

We can also use the SDK if we need to for some reason.

The response will contain a Token property. The value will be the Cognito identity pool ID token whose sub property will be the same as the IdentityId. The token is valid for 10 minutes, and its payload looks like this:

{
  // identity pool user ID - this is what we need for this solution!
  "sub": "eu-central-1:6f8d10b7-98fb-41ca-977d-f55318b0508f",
  // identity pool ID - we can get it from the Console
  "aud": "eu-central-1:121ebb41-a712-4ee7-9d33-800b4cb96a1c",
  "amr": [
    // shows that the user has signed in and authenticated
    "authenticated",
    // user pool ID - we can get it from the Console
    "cognito-idp.eu-central-1.amazonaws.com/eu-central-1_SOMETHING",
    // user pool user ID - the sub in the user pool ID token
    "cognito-idp.eu-central-1.amazonaws.com/eu-central-1_SOMETHING:CognitoSignIn:f439853a-e70c-46d6-bd29-fb4a626a62d2"
  ],
  "https://aws.amazon.com/tags": {
    "principal_tags": {
      // the userid principal tag from Part 2 - we mapped the user's sub to it
      "userid": [
        "439853a-e70c-46d6-bd29-fb4a626a62d2"
      ]
    }
  },
  "iss": "https://cognito-identity.amazonaws.com",
  // ... more properties
}

This token contains everything IAM needs to know to read the sub’s (and the userid tag’s) value for authorization.

4. Code

So we could call the GetId endpoint and extract the identity pool user ID, i.e., IdentityId from the response.

We can do something like this in the React component:

import { GetIdCommand, CognitoIdentityClient } from '@aws-sdk/client-cognito-identity';
// ...more imports

const identityPoolClient = new CognitoIdentityClient({ region: 'eu-central-1' });

function App({ user }) {
  const [cognitoId, setCognitoId] = React.useState('');

  // 1. Get the ID token issued by the USER POOL
  const idToken = user.signInUserSession.idToken.jwtToken;

  // 2. Call GetId to receive the IDENTITY POOL user ID and store it
  const getIdInput = {
    IdentityPoolId: 'IDENTITY_POOL_ID',
    Logins: {
      'cognito-idp.eu-central-1.amazonaws.com/USER_POOL_ID': idToken,
    },
  };
  const getIdCommand = new GetIdCommand(getIdInput);

  useEffect(() => {
    async function getId() {
      const response = await identityPoolClient.send(getIdCommand);
      setCognitoId(response.IdentityId);
    }

    getId();
  }, []);

  // ...configure client and do other stuff

  const queryInput = {
    ExpressionAttributeValues: {
      // 3. Refer to the IDENTITY POOL user ID in the query
      ':sub': cognitoId,
    },
    KeyConditionExpression: 'userId = :sub',
    TableName: 'AgentsTable',
  };
  const command = new QueryCommand(queryInput);

  // ... call Query API and display the items
}

First, we need the ID token that the user pool has issued (1). The token will identify the user. Next, we’ll call GetId with the ID token and the identity pool ID (2). The response’s IdentityId will be the identity pool user ID. We’ll take it and query the table for the specific items (3).

5. Considerations and acknowledgment

Have the correct partition key

The identity pool user ID has a format of REGION:UUID, the same format as the identity pool ID. It’s another potential source of confusion!

So we should have the partition key in this format, for example, eu-central-1:6f8d10b7-98fb-41ca-977d-f55318b0508f. If we only have 6f8d10b7-98fb-41ca-977d-f55318b0508f, it won’t work. The sub in the policy variable refers, and the sub in the identity pool’s ID token should match.

Optimize the code

The above React code is here for demonstration purposes only, and you can (and should) optimize it further. It’s NOT a production-level code.

Thanks to AWS support

I approached the AWS documentation support with my confusion about the wording, and they responded quickly. Their response helped and took me several steps closer to this solution. Thanks!

6. Summary

We can control access to specific DynamoDB items in different ways. The AWS official documentation recommends using the ${cognito-identity.amazonaws.com:sub} policy variable. The sub value is the identity pool user ID, which we can get by calling the GetId API. We must ensure the item’s partition key in the table matches this ID.

7. References, further reading

Tutorial: Creating a user pool - How to create a user pool

Tutorial: Creating an identity pool - How to create an identity pool

IAM JSON policy elements: Condition operators - More information about the Condition operators

Getting started with DynamoDB - Basic DynamoDB user guide