aws-cdk-lib/aws-apigatewayv2: WebSocketLambdaIntegration only creates lambda permissions for the $connectRoute when reusing the integration
- Dominant language
- TypeScript
- Stars
- 12.9k
- Forks
- 4.6k
- Avg merge
- 2d 3h
- Merged PRs (30d)
- 83
Description
### Describe the bug
When a `WebSocketApi` is created with `connectRouteOptions`, `disconnectRouteOptions`, and `defaultRouteOptions` all pointing to the same `WebSocketLambdaIntegration` instance, only the `$connect` route gets a `Lambda::Permission` resource in the synthesized template. The `$disconnect` and `$default` routes are missing their permissions, causing API Gateway to fail with `API_CONFIGURATION_ERROR` at runtime.
Custom routes added via `addRoute()` with their own integration instances correctly get permissions.
Related issue: https://github.com/aws/aws-cdk/issues/21080
Verified in behavior still occurring in 2.240.0
### Regression Issue
- [ ] Select this option if this issue appears to be a regression.
### Last Known Working CDK Library Version
_No response_
### Expected Behavior
The synthesized template should contain `AWS::Lambda::Permission` resources for **all three** routes:
```json
{
"Type": "AWS::Lambda::Permission",
"Properties": {
"Action": "lambda:InvokeFunction",
"FunctionName": { "Ref": "HandlerAliasLive" },
"Principal": "apigateway.amazonaws.com",
"SourceArn": { "Fn::Join": ["", ["arn:...:execute-api:...", { "Ref": "WebSocketApi" }, "/*$connect"]] }
}
}
```
```json
{
"Type": "AWS::Lambda::Permission",
"Properties": {
"Action": "lambda:InvokeFunction",
"FunctionName": { "Ref": "HandlerAliasLive" },
"Principal": "apigateway.amazonaws.com",
"SourceArn": { "Fn::Join": ["", ["arn:...:execute-api:...", { "Ref": "WebSocketApi" }, "/*$disconnect"]] }
}
}
```
```json
{
"Type": "AWS::Lambda::Permission",
"Properties": {
"Action": "lambda:InvokeFunction",
"FunctionName": { "Ref": "HandlerAliasLive" },
"Principal": "apigateway.amazonaws.com",
"SourceArn": { "Fn::Join": ["", ["arn:...:execute-api:...", { "Ref": "WebSocketApi" }, "/*$default"]] }
}
}
```
### Current Behavior
The synthesized CloudFormation template contains:
- ✅ `AWS::ApiGatewayV2::Route` for `$connect`, `$disconnect`, and `$default` (all three routes are created correctly)
- ✅ `AWS::Lambda::Permission` for `$connect` (source ARN `/*$connect`)
- ❌ **No** `AWS::Lambda::Permission` for `$disconnect`
- ❌ **No** `AWS::Lambda::Permission` for `$default`
All three routes reference the same integration resource (`WSIntegration`), but only one permission is generated.
At runtime, API Gateway returns `status: 500` with `errorResponseType: "API_CONFIGURATION_ERROR"` for `$disconnect` because it lacks permission to invoke the Lambda.
### Reproduction Steps
```typescript
import { WebSocketApi, WebSocketStage } from 'aws-cdk-lib/aws-apigatewayv2';
import { WebSocketLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations';
import { Function, Runtime, Code } from 'aws-cdk-lib/aws-lambda';
import { Stack, App } from 'aws-cdk-lib';
const app = new App();
const stack = new Stack(app, 'TestStack');
const handler = new Function(stack, 'Handler', {
runtime: Runtime.NODEJS_20_X,
handler: 'index.handler',
code: Code.fromInline('exports.handler = async () => ({ statusCode: 200 })'),
});
const alias = handler.addAlias('live');
// Single integration instance shared across $connect, $disconnect, $default
const wsIntegration = new WebSocketLambdaIntegration('WSIntegration', alias);
const api = new WebSocketApi(stack, 'WebSocketApi', {
apiName: 'MyWebSocketApi',
connectRouteOptions: { integration: wsIntegration },
disconnectRouteOptions: { integration: wsIntegration },
defaultRouteOptions: { integration: wsIntegration },
});
new WebSocketStage(stack, 'Stage', {
webSocketApi: api,
stageName: 'v1',
autoDeploy: true,
});
```
### Possible Solution
#### Root Cause
There are two compounding issues:
#### Issue 1: `_bindToRoute()` only calls `bind()` once
The base class [`WebSocketRouteIntegration._bindToRoute()`](https://github.com/aws/aws-cdk/blob/0669743eae532c6780fc8f9b555a6b49f8dee3dd/packages/aws-cdk-lib/aws-apigatewayv2/lib/websocket/integration.ts) caches after the first invocation:
```typescript
public _bindToRoute(options: WebSocketRouteIntegrationBindOptions): { readonly integrationId: string } {
if (!this.integration) {
const config = this.bind(options); // <-- Only called ONCE
this.integration = new WebSocketIntegration(options.scope, this.id, { ... });
}
return { integrationId: this.integration.integrationId };
}
```
When the same integration instance is reused across multiple routes:
1. First route (`$connect`) → `this.integration` is null → calls `this.bind(options)` → `addPermission` runs ✅
2. Second route (`$disconnect`) → `this.integration` already exists → **skips `this.bind()` entirely** ❌
3. Third route (`$default`) → same as above ❌
The caching is correct for the `CfnIntegration` resource (you only want one), but it prevents `addPermission` from running for subsequent routes.
### Issue 2: Permission ID is not unique per route
Even if `bind()` were called for every route, [`WebSocketLambdaIntegration.bind()`](https://github.com/aws/aws-cdk/blob/0669743eae532c6780fc8f9b555a6b49f8dee3dd/packages/aws-cdk-lib/aws-apigatewayv2-integrations/lib/websocket/lambda.ts) uses a static permission ID:
```typescript
this.handler.addPermission(`${this._id}-Permission`, {
sourceArn: Stack.of(route).formatArn({
service: 'execute-api',
resource: route.webSocketApi.apiId,
resourceName: `*${route.routeKey}`,
}),
});
```
Since `this._id` is constant (e.g., `'WSHandlerIntegration'`), the permission ID is always `WSHandlerIntegration-Permission`. `IFunction.addPermission()` is idempotent by ID — calling it multiple times with the same ID but different `sourceArn` values silently no-ops after the first call. So even if the caching issue in `_bindToRoute` were fixed, the permissions would still deduplicate.
### Additional Information/Context
#### Workaround 1
Create separate integration for each route:
```typescript
const api = new WebSocketApi(stack, 'WebSocketApi', {
apiName: 'MyWebSocketApi',
connectRouteOptions: { integration: new WebSocketLambdaIntegration('WSConnectIntegration', alias) },
disconnectRouteOptions: { integration: new WebSocketLambdaIntegration('WSDisconnectIntegration', alias) },
defaultRouteOptions: { integration: new WebSocketLambdaIntegration('WSDefaultIntegration', alias) },
});
```
#### Workaround 2
Manually add permissions on the Lambda alias:
```typescript
alias.addPermission('DisconnectRoutePermission', {
principal: new ServicePrincipal('apigateway.amazonaws.com'),
sourceArn: api.arnForExecuteApiV2('$disconnect', stage.stageName),
});
alias.addPermission('DefaultRoutePermission', {
principal: new ServicePrincipal('apigateway.amazonaws.com'),
sourceArn: api.arnForExecuteApiV2('$default', stage.stageName),
});
```
## Notes
- Custom routes added via `api.addRoute('myRoute', { integration: new WebSocketLambdaIntegration(...) })` correctly generate their own permissions because each uses a distinct integration instance.
- The issue only manifests when a single `WebSocketLambdaIntegration` instance is reused across multiple routes passed to the `WebSocketApi` constructor.
-
### AWS CDK Library version (aws-cdk-lib)
2.240.0
### AWS CDK CLI version
2.1125.0
### Node.js Version
10.4.5
### OS
AL2023
### Language
TypeScript
### Language Version
Typescript (5.9.3)
### Other information
Related: https://stackoverflow.com/questions/62118233/websocket-request-succeeds-in-writing-to-dynamodb-but-returns-internal-server-er/72716478#72716478
Contributor guide
Research direction
Start by reading packages/aws-cdk-lib/aws-apigatewayv2/lib/websocket/integration.ts and packages/aws-cdk-lib/aws-apigatewayv2-integrations/lib/websocket/lambda.ts, then run the TypeScript reproduction and synthesize its CloudFormation template. Done means the shared integration produces Lambda permissions for the $connect, $disconnect, and $default routes without breaking custom routes.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- aws, typescript
- Domain
- api, cloud, infrastructure
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 55/100