Fixing CloudFormation Circular Dependency Errors in AWS Amplify Gen 2.

AWS Amplify deployment log showing the CloudFormation circular dependency error between the storage, auth, data and function nested stacks

TLDR

When your Lambda function needs both Cognito access AND is used as a data handler, use resourceGroupName: "auth" in your function definition to avoid circular dependencies.

The Problem

If you're building with AWS Amplify Gen 2 and creating Lambda functions that interact with Cognito (for user management, group operations, etc.) while also using them as GraphQL resolvers, you've probably hit this frustrating error:

[ERROR] [CloudformationStackCircularDependencyError]
The CloudFormation deployment failed due to circular dependency found
between nested stacks [storage0YH5D, auth17956DE9, data74490FG5R, function164FR5G3]

This happens because Amplify Gen 2 organizes resources into separate CloudFormation nested stacks:

  • Auth stack - Cognito User Pool, groups, triggers.
  • Data stack - AppSync API, DynamoDB tables, resolvers.
  • Function stack - Lambda functions (by default).
  • Storage stack - S3 buckets.

When your function needs resources from multiple stacks, CloudFormation can't determine the deployment order, creating a circular dependency.

Real-World Scenario

I was building an admin panel for user role management. The Lambda functions needed to:

  • Call Cognito APIs (AdminAddUserToGroup, ListUsersInGroup, etc.).
  • Be used as GraphQL mutation/query handlers in the data schema.

Here's what my initial setup looked like:

// amplify/functions/add-user-to-group/resource.ts
export const addUserToGroup = defineFunction({
  name: "add-user-to-group",
  entry: "./handler.ts",
});

// amplify/auth/resource.ts
export const auth = defineAuth({
  // ...
  access: (allow) => [
    allow.resource(addUserToGroup).to(["addUserToGroup", "listUsers"]),
  ],
});

// amplify/data/resource.ts - using the function as a handler
addUserToGroup: a
  .mutation()
  .handler(a.handler.function(addUserToGroup))
  .authorization((allow) => [allow.group("ADMINS")]),

Result: Circular dependency error. The auth stack imports the function, the data stack imports the function, and the function stack depends on both 💥.

The Solution: resourceGroupName

Amplify Gen 2 provides a simple but powerful property: resourceGroupName. This tells Amplify which nested stack to place your function in.

The Fix

// amplify/functions/add-user-to-group/resource.ts
export const addUserToGroup = defineFunction({
  name: "add-user-to-group",
  entry: "./handler.ts",
  resourceGroupName: "auth", // ← This is the magic line
});

By placing the function in the auth stack, we break the circular dependency:

  • The function is now part of the auth stack, not a separate dependency.
  • The data stack can still reference it as a handler.
  • Auth permissions are granted within the same stack.

Complete Working Example 1

1. Function Definition

// amplify/functions/add-user-to-group/resource.ts
import { defineFunction } from "@aws-amplify/backend";

export const addUserToGroup = defineFunction({
  name: "add-user-to-group",
  entry: "./handler.ts",
  timeoutSeconds: 30,
  memoryMB: 256,
  resourceGroupName: "auth", // Place in auth stack
});

2. Auth Resource with Access Grants

// amplify/auth/resource.ts
import { defineAuth } from "@aws-amplify/backend";
import { addUserToGroup } from "../functions/add-user-to-group/resource";

export const auth = defineAuth({
  loginWith: { email: true },
  groups: ["ADMINS", "EDITORS"],
  access: (allow) => [
    allow.resource(addUserToGroup).to(["addUserToGroup", "listUsers"]),
  ],
});

3. Data Schema Using the Function

// amplify/data/resource.ts
import { addUserToGroup } from "../functions/add-user-to-group/resource";

const schema = a.schema({
  addUserToGroup: a
    .mutation()
    .arguments({
      email: a.string().required(),
      groupName: a.string().required(),
    })
    .returns(
      a.customType({
        success: a.boolean(),
        message: a.string(),
      })
    )
    .handler(a.handler.function(addUserToGroup))
    .authorization((allow) => [allow.group("ADMINS")]),
});

4. Lambda Handler

// amplify/functions/add-user-to-group/handler.ts
import {
  CognitoIdentityProviderClient,
  AdminAddUserToGroupCommand,
  ListUsersCommand,
} from "@aws-sdk/client-cognito-identity-provider";
import { env } from "$amplify/env/add-user-to-group";

const cognitoClient = new CognitoIdentityProviderClient({});

export const handler = async (event) => {
  const { email, groupName } = event.arguments;

  // Look up user by email
  const listUsersResponse = await cognitoClient.send(
    new ListUsersCommand({
      UserPoolId: env.AMPLIFY_AUTH_USERPOOL_ID, // Auto-injected by Amplify
      Filter: `email = "${email}"`,
    })
  );

  const username = listUsersResponse.Users?.[0]?.Username;

  // Add to group
  await cognitoClient.send(
    new AdminAddUserToGroupCommand({
      UserPoolId: env.AMPLIFY_AUTH_USERPOOL_ID,
      Username: username,
      GroupName: groupName,
    })
  );

  return { success: true, message: `User added to ${groupName}` };
};

5. Don't Forget the package.json

// amplify/functions/add-user-to-group/package.json
{
  "name": "add-user-to-group",
  "type": "module",
  "dependencies": {
    "@aws-sdk/client-cognito-identity-provider": "^3.0.0"
  }
}

Run npm install in the function directory!

Example 2: Storage + Data Circular Dependency

Another common scenario is when a Lambda function needs to access both Storage (S3) and Data (DynamoDB). This happens frequently with scheduled cleanup jobs, file processing pipelines, or any function that manages files while tracking state in a database.

The Scenario

I was building a scheduled cleanup function that:

  • Queries DynamoDB to find old voice recording sessions
  • Deletes the associated audio files from S3
  • Updates the session records to clear the recording references

The Initial (Broken) Setup

My first attempt used allow.resource() in the storage definition:

// amplify/storage/resource.ts
import { defineStorage } from "@aws-amplify/backend";
import { recordingCleanup } from "../functions/recording-cleanup/resource";

export const storage = defineStorage({
  name: "my-app-storage",
  access: (allow) => ({
    "audio/*": [
      allow.authenticated.to(["read", "write"]),
      allow.resource(recordingCleanup).to(["read", "delete"]), // ← Problem!
    ],
  }),
});
// amplify/functions/recording-cleanup/resource.ts
import { defineFunction } from "@aws-amplify/backend";

export const recordingCleanup = defineFunction({
  name: "recording-cleanup",
  entry: "./handler.ts",
  schedule: "every week",
  resourceGroupName: "data", // Function needs DynamoDB access
});
// amplify/backend.ts - granting DynamoDB permissions
backend.recordingCleanup.resources.lambda.addToRolePolicy(
  new PolicyStatement({
    actions: ["dynamodb:Query", "dynamodb:Scan", "dynamodb:UpdateItem"],
    resources: [backend.data.resources.tables["Session"].tableArn],
  })
);

Result: Circular dependency error between storage and data stacks 💥

Why It Fails

The dependency chain creates a loop:

  1. Storage imports recordingCleanup function → storage depends on function
  2. Function is in data stack and references data tables in backend.ts → data stack is involved
  3. backend.ts references backend.storage for the function → data depends on storage

CloudFormation can't resolve this circular reference.

The Fix: Choose Primary Stack, Grant Secondary via CDK

The solution is to remove the import dependency from storage and grant S3 permissions via CDK instead:

// amplify/storage/resource.ts - NO function import
import { defineStorage } from "@aws-amplify/backend";

export const storage = defineStorage({
  name: "my-app-storage",
  access: (allow) => ({
    "audio/*": [
      allow.authenticated.to(["read", "write"]),
      // Function access granted via CDK in backend.ts
    ],
  }),
});
// amplify/functions/recording-cleanup/resource.ts
import { defineFunction } from "@aws-amplify/backend";

export const recordingCleanup = defineFunction({
  name: "recording-cleanup",
  entry: "./handler.ts",
  schedule: "every week",
  resourceGroupName: "data", // Keep in data stack (primary resource)
});
// amplify/backend.ts - grant BOTH permissions via CDK
import { PolicyStatement } from "aws-cdk-lib/aws-iam";

// DynamoDB permissions
backend.recordingCleanup.resources.lambda.addToRolePolicy(
  new PolicyStatement({
    actions: ["dynamodb:Query", "dynamodb:Scan", "dynamodb:UpdateItem"],
    resources: [
      backend.data.resources.tables["Session"].tableArn,
      `${backend.data.resources.tables["Session"].tableArn}/index/*`,
    ],
  })
);

// S3 permissions (instead of allow.resource())
backend.recordingCleanup.resources.lambda.addToRolePolicy(
  new PolicyStatement({
    actions: ["s3:DeleteObject", "s3:GetObject", "s3:ListBucket"],
    resources: [
      backend.storage.resources.bucket.bucketArn,
      `${backend.storage.resources.bucket.bucketArn}/*`,
    ],
  })
);

// Pass bucket name as environment variable
(
  backend.recordingCleanup.resources
    .lambda as import("aws-cdk-lib/aws-lambda").Function
).addEnvironment(
  "STORAGE_BUCKET_NAME",
  backend.storage.resources.bucket.bucketName
);
// amplify/functions/recording-cleanup/handler.ts
import { S3Client, DeleteObjectCommand } from "@aws-sdk/client-s3";

const s3Client = new S3Client({});
const BUCKET_NAME = process.env.STORAGE_BUCKET_NAME!;

export const handler = async () => {
  // Query DynamoDB for old sessions...
  // Delete from S3 using BUCKET_NAME...
};

Why This Works

By removing the allow.resource() import from storage:

  • Storage no longer depends on the function definition
  • Function stays in data stack (its primary resource)
  • S3 permissions flow one-way: data stack → storage resources
  • No circular dependency!

Decision Guide Update

Function needs...resourceGroupNameGrant access via
Cognito operations"auth"auth.access callback
Data API / AppSync"data"Schema .authorization()
Storage (S3) only"storage"storage.access with allow.resource()
Both Data AND Storage"data"Data via schema, S3 via CDK
Both Auth AND Data handler"auth"Auth via access callback

Key Takeaway

When your function needs multiple resource types, pick the primary stack and grant secondary permissions via CDK. The allow.resource() pattern is convenient but creates import dependencies that can cause circular references. CDK's addToRolePolicy() creates one-way IAM dependencies that CloudFormation can resolve.x

Decision Guide

Your function needs...Use resourceGroupNameWhy
Cognito operations"auth"Function joins auth stack, gets permissions via access callback
Data API / AppSync"data"Function joins data stack, gets permissions via schema authorization
Storage (S3) only"storage"storage.access with allow.resource()
Both Cognito AND Data handler"auth"Prioritize auth since Cognito permissions require the access callback
Both Data AND Storage"data"Data via schema, S3 via CDK
Auth trigger (pre-signup, etc.)"auth"Must be in same stack as auth resource
Only S3 access(default)Use IAM policies in backend.ts

Common Mistakes to Avoid

❌ Don't manually grant IAM policies when access callback works

// DON'T DO THIS for auth operations
backend.myFunction.resources.lambda.addToRolePolicy(
  new PolicyStatement({
    actions: ["cognito-idp:AdminAddUserToGroup"],
    resources: [userPoolArn],
  })
);

✅ Do use the Amplify-native access callback

// DO THIS instead
export const auth = defineAuth({
  access: (allow) => [allow.resource(myFunction).to(["addUserToGroup"])],
});

The access callback:

  • Automatically injects AMPLIFY_AUTH_USERPOOL_ID environment variable.
  • Grants least-privilege IAM permissions.
  • Works seamlessly with resourceGroupName: "auth".

Debugging Tips

  • Check your imports - Make sure auth/resource.ts and data/resource.ts are importing from the same function definition file.

  • Verify resourceGroupName - The value must be exactly "auth" or "data" (lowercase strings).

  • Clear the sandbox - Sometimes you need to delete and recreate: npx ampx sandbox delete && npx ampx sandbox.

  • Check CloudFormation - In AWS Console, look at the nested stacks to see where resources ended up.

Preventing This Error with Kiro Steering Docs

After hitting this error multiple times, I decided to create a steering document using Kiro - an AI-powered IDE that supports contextual guidance through steering files.

What are Steering Docs?

Steering docs are markdown files that provide context-aware guidance to AI assistants. They live in your .kiro/steering/ directory and can be:

  • Always included - Applied to every AI interaction.
  • File-matched - Only included when working on specific files.
  • Manual - Included on-demand via context keys.

Creating a Circular Dependency Prevention Steering Doc

I created .kiro/steering/amplify-circular-dependency.md with file matching:

---
inclusion: fileMatch
fileMatchPattern: "amplify/**/*.ts"
---

# Amplify Gen 2 Circular Dependency Prevention

## Checklist Before Creating Lambda Functions

- [ ] Does this function need Cognito access? → Use `resourceGroupName: "auth"`
- [ ] Does this function need Data API access? → Use `resourceGroupName: "data"`
- [ ] Is this function used as a data handler AND needs Cognito? → Use `resourceGroupName: "auth"`

## Decision Matrix

| Function needs...  | resourceGroupName | Grant access via          |
| ------------------ | ----------------- | ------------------------- |
| Cognito operations | `"auth"`          | `auth.access` callback    |
| Data API / AppSync | `"data"`          | Schema `.authorization()` |
| Storage (S3) only  | (default)         | `backend.ts` IAM policy   |
| Auth trigger       | `"auth"`          | `auth.triggers`           |

How It Works

Now, whenever I (or the AI assistant) work on any file in the amplify/ directory, Kiro automatically includes this guidance. The AI is reminded to:

  • Check if the function needs Cognito access.
  • Use the correct resourceGroupName.
  • Grant permissions via the appropriate mechanism.

This has completely eliminated the circular dependency errors from my workflow. The AI catches the issue before it happens, not after a failed deployment.

Why This Matters

  • Proactive prevention - Catch issues before they cause deployment failures.
  • Team knowledge sharing - New team members get the same guidance.
  • AI-assisted development - The AI learns your project's patterns.
  • Living documentation - Steering docs evolve with your codebase.

Check out Kiro to add steering docs to your own projects and prevent recurring errors like this one.

Conclusion

The resourceGroupName property is a simple but essential tool for managing complex Amplify Gen 2 backends. When your Lambda functions need to interact with multiple Amplify resources (auth, data, storage), think about which stack they belong in to avoid circular dependencies.

Rule of thumb: If your function needs Cognito access, put it in the auth stack. If it needs Data API access, put it in the data stack. If it needs both, auth usually wins because Cognito permissions require the access callback.

And if you want to prevent this error from ever happening again, use Kiro steering docs to encode this knowledge directly into your development workflow.

References

  • AWS Cognito User Pool Attributes Cannot Be Changed: A Deep Dive.

    User pool attributes cannot be changed after a user pool has been created because Cognito User Pool attributes are immutable after creation. Merging branches with different auth configs breaks deployments. Quick Fix: Delete the CloudFormation stack (aws cloudformation delete-stack --stack-name YOUR-STACK) and redeploy. Warning: this deletes all users. Prevent It: Define all user attributes and groups before your first production deploy. Use separate Amplify apps per environment instead of branch-based deployments.

  • Can't resolve amplify_outputs.json

    To fix the can't resolve amplify_outputs.json build error add npx ampx pipeline-deploy --branch $AWS_BRANCH --app-id $AWS_APP_ID1 to the build settings and attach the AdministratorAccess-Amplify policy

  • Asynchronous update of related items with AWS DataStore in a Next.js web app.

    When working with AWS DataStore you have to deal with async/await operations. Updating a list of items when the order of async operations execution is not mandatory is viable to do with Promise.all() and map. Updating related items, where the promise result from the previous item is needed as input for the next item, can be achieved with the for await...of statement.