aws / aws/aws-cdk

aws-cdk-lib/aws-apigatewayv2: WebSocketLambdaIntegration only creates lambda permissions for the $connectRoute when reusing the integration

Closed
#38,148 3 comments 0 reactions 0 assignees View on GitHub
@aws-cdk/aws-lambda bug effort/medium p1
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.