ALB + Lambda 構成でAPIキー認証を実装する方法を試してみた

ALB + Lambda 構成でAPIキー認証を実装する方法を試してみた

2025.12.20

製造ビジネステクノロジー部の小林です。

AWS で Web API を構築する際、「ALB + Lambda」 の構成は、タイムアウト制限(API Gatewayの29秒)を回避したい場合や、既存のALB環境を利用したい場合に有力な選択肢となります。

しかし、ALB には API Gateway のような「管理されたAPIキー機能」が標準ではありません。 そこで今回は AWS CDK を使って ALB + Lambda 環境を構築し、以下の2つの方法でAPIキー認証を実装・比較してみます。

  1. ALB リスナールール
  2. AWS WAF

前提条件

  • AWS CDK v2(TypeScript)
  • Node.js (v22系)
  • express
  • AWS CLI

事前準備:APIキーの登録

APIキーをコードにベタ書きするのは推奨されないため、AWS Systems Manager (SSM) Parameter Store を利用します。

ここでセキュリティを意識して暗号化された SecureString を使いたくなりますが、ALBのリスナールールは、 CloudFormation の SecureString 動的参照に対応していません。

もし SecureString を使ってCDKデプロイしようとすると、以下のエラーが発生します。

ToolkitError: ... SSM Secure reference is not supported in: [AWS::ElasticLoadBalancingV2::ListenerRule/Properties/Conditions]

そのため、ALBリスナールールで認証を行う場合は、String (平文) でパラメータストアに保存する必要があります。

APIキーの登録コマンド

以下のコマンドで、String 型としてキーを登録します。

# AWS CLIで登録
aws ssm put-parameter \
  --name "/app/alb/api-key" \
  --value "my-api-key" \
  --type "String"

コンソールで確認すると String 型 でキーが登録されました。
スクリーンショット 2025-12-20 23.20.54

CDKの実装

CDKプロジェクトを作成し、必要なライブラリをインストールします。

# CDKプロジェクトの作成
cdk init app --language typescript

# Lambda(Express)用
npm install express serverless-http
npm install --save-dev @types/express @types/aws-lambda esbuild

アプリケーションコード

lambda/src/index.ts を作成します。

import express from 'express';
import serverless from 'serverless-http';

const app = express();

app.get('/', (req, res) => {
  res.json({
    message: 'Authentication Successful!',
    runtime: process.version,
  });
});

export const handler = serverless(app);

ALBリスナールール

リスナールール を使った実装です。 ALBの機能で「特定のヘッダーを持たないリクエスト」を Lambda に届く前にブロックします。

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as nodejs from 'aws-cdk-lib/aws-lambda-nodejs';
import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2';
import * as targets from 'aws-cdk-lib/aws-elasticloadbalancingv2-targets';
import * as ssm from 'aws-cdk-lib/aws-ssm'; // SSMモジュール

export class CdkAlbApikeyStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    /**
     * APIキーの取得
     * ALBリスナールール用に String (平文) パラメータとして取得
     */
    const apiKey = ssm.StringParameter.valueForStringParameter(this, '/app/alb/api-key');
    const HEADER_NAME = 'x-api-key';

    /**
     * VPC と Lambda 関数の構築
     */
    const vpc = new ec2.Vpc(this, 'AppVpc', {
      maxAzs: 2,
      natGateways: 0,
      subnetConfiguration: [
        { cidrMask: 24, name: 'Public', subnetType: ec2.SubnetType.PUBLIC },
      ],
    });

    const fn = new nodejs.NodejsFunction(this, 'ExpressFunc', {
      entry: 'lambda/src/index.ts',
      handler: 'handler',
      runtime: lambda.Runtime.NODEJS_22_X,
    });

    /**
     * ALB 構築
     */
    const lb = new elbv2.ApplicationLoadBalancer(this, 'AppLB', {
      vpc,
      internetFacing: true,
    });

    const listener = lb.addListener('HttpListener', {
      port: 80,
      open: true,
    });

    /**
     * ALB リスナールールの設定
     * 認証ロジック (リスナールール版)
     */
    listener.addAction('DefaultBlock', {
      action: elbv2.ListenerAction.fixedResponse(403, {
        contentType: 'application/json',
        messageBody: JSON.stringify({ error: 'Forbidden: Invalid API Key' }),
      }),
    });

    // 【許可ルール】APIキーが一致する場合のみ転送
    listener.addTargets('AllowWithKey', {
      priority: 10,
      conditions: [
        // SSMから取得した値を設定
        elbv2.ListenerCondition.httpHeader(HEADER_NAME, [apiKey]),
      ],
      targets: [new targets.LambdaTarget(fn)],
      healthCheck: { enabled: true, path: '/' },
    });

    new cdk.CfnOutput(this, 'AlbDnsName', { value: lb.loadBalancerDnsName });
  }
}

AWSコンソールでALBのリスナールールを確認してみます。 APIキーはコード上からは隠蔽しましたが、ALBの設定値として展開されるため、コンソール上ではこのように平文で見えてしまう点に注意が必要です。
スクリーンショット 2025-12-20 23.58.56

デプロイと動作確認

ここで一度デプロイして、ALBのリスナールールが正しく機能しているか確認します。

出力された AlbDnsName (例: http://CdkAl-AppLB-xxxx.ap-northeast-1.elb.amazonaws.com) に対して curl でリクエストを送ります。

成功パターン

正しいAPIキーをヘッダーにセットします。

curl -H "x-api-key: my-api-key" http://<ALB-DNS-NAME>/

結果

{"message":"Authentication Successful!","runtime":"v22.x.x"}

Lambdaからのレスポンスが返ってきました。

失敗パターン(ブロック確認)

キーを設定しない、または間違ったキーで送ります。

curl http://<ALB-DNS-NAME>/

結果

{"error":"Forbidden: Invalid API Key"}

Lambdaには到達せず、ALBのリスナールールで 403 Forbidden が返されました。成功です!

AWS WAF を使うパターン

ALBリスナールールの代わりに AWS WAF を使用します。 WAF を使うと、IP制限やレートリミット(Dos攻撃対策)なども合わせて実装できます。CDK で WAF を実装する場合、ALB 側のルールは単純な転送に戻し、WAF の WebACL を作成して ALB に関連付けます。

lib/cdk-alb-apikey-stack.ts を以下のように書き換えます。

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as nodejs from 'aws-cdk-lib/aws-lambda-nodejs';
import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2';
import * as targets from 'aws-cdk-lib/aws-elasticloadbalancingv2-targets';
import * as ssm from 'aws-cdk-lib/aws-ssm';
import * as wafv2 from 'aws-cdk-lib/aws-wafv2';

export class CdkAlbApikeyStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    /**
     * APIキーの取得
     * ALBリスナールール用に String (平文) パラメータとして取得
     */
    const apiKey = ssm.StringParameter.valueForStringParameter(this, '/app/alb/api-key');
    const HEADER_NAME = 'x-api-key';

    /**
     * VPC と Lambda 関数の構築
     */
    const vpc = new ec2.Vpc(this, 'AppVpc', {
      maxAzs: 2,
      natGateways: 0,
      subnetConfiguration: [
        { cidrMask: 24, name: 'Public', subnetType: ec2.SubnetType.PUBLIC },
      ],
    });

    const fn = new nodejs.NodejsFunction(this, 'ExpressFunc', {
      entry: 'lambda/src/index.ts',
      handler: 'handler',
      runtime: lambda.Runtime.NODEJS_22_X,
    });

    /**
     * ALB 構築
     */
    const lb = new elbv2.ApplicationLoadBalancer(this, 'AppLB', {
      vpc,
      internetFacing: true,
    });

    const listener = lb.addListener('HttpListener', {
      port: 80,
      open: true,
    });

    // /**
    //  * ALB リスナールールの設定
    //  * 認証ロジック (リスナールール版)
    //  */
    // listener.addAction('DefaultBlock', {
    //   action: elbv2.ListenerAction.fixedResponse(403, {
    //     contentType: 'application/json',
    //     messageBody: JSON.stringify({ error: 'Forbidden: Invalid API Key' }),
    //   }),
    // });

    // // 【許可ルール】APIキーが一致する場合のみ転送
    // listener.addTargets('AllowWithKey', {
    //   priority: 10,
    //   conditions: [
    //     // SSMから取得した値を設定
    //     elbv2.ListenerCondition.httpHeader(HEADER_NAME, [apiKey]),
    //   ],
    //   targets: [new targets.LambdaTarget(fn)],
    //   healthCheck: { enabled: true, path: '/' },
    // });

   /**
     * ALB リスナールールの設定
     * WAF導入版: ALBは条件なしでLambdaへ転送(DefaultTarget)のみ設定
     */
    listener.addTargets('DefaultTarget', {
      targets: [new targets.LambdaTarget(fn)],
      healthCheck: { enabled: true, path: '/' },
    });

    new cdk.CfnOutput(this, 'AlbDnsName', { value: lb.loadBalancerDnsName });

    /**
     * WAF Web ACLの定義
     */
    const webAcl = new wafv2.CfnWebACL(this, 'ApiAuthWebACL', {
      defaultAction: { block: {} },
      scope: 'REGIONAL',
      visibilityConfig: {
        cloudWatchMetricsEnabled: true,
        metricName: 'ApiAuthWebACL',
        sampledRequestsEnabled: true,
      },
      rules: [
        {
          name: 'ApiKeyRule',
          priority: 1,
          action: { allow: {} }, // 一致したら許可
          statement: {
            byteMatchStatement: {
              searchString: apiKey, // SSMの値
              fieldToMatch: { singleHeader: { name: 'x-api-key' } },
              positionalConstraint: 'EXACTLY',
              textTransformations: [{ priority: 0, type: 'NONE' }]
            }
          },
          visibilityConfig: {
            cloudWatchMetricsEnabled: true,
            metricName: 'ApiKeyRule', // ルールごとに一意な名前
            sampledRequestsEnabled: true,
          }
        }
      ]
    });

    /**
     * WAF Web ACL を ALB に関連付け
     */
    new wafv2.CfnWebACLAssociation(this, 'WebAclAssoc', {
      resourceArn: lb.loadBalancerArn,
      webAclArn: webAcl.attrArn,
    });
  }
}

visibilityConfig を設定することで、CloudWatchメトリクスで「どれくらいブロックされたか」「どのルールに引っかかったか」を確認できるようになります。

WAF版のデプロイと動作確認

コードを修正したらデプロイします。デプロイ完了後、先ほどと同じ AlbDnsName に対してリクエストを送ってみましょう。

成功パターンの確認

正しいAPIキーがある場合は、これまで通り Lambda からレスポンスが返ります。

curl -H "x-api-key: my-api-key" http://<ALB-DNS-NAME>/

結果

{"message":"Authentication Successful!",...}

失敗パターンの確認(ここが変わります!)

キーなし、または間違ったキーでリクエストを送ります。

curl -v http://<ALB-DNS-NAME>/

結果
これまでALBリスナールールで返していた 403 Forbidden ({"error":...}) というJSONレスポンスではなく、WAFによるデフォルトの 403 HTMLページ(または403エラー)が返ってくるようになります。

<html>
<head><title>403 Forbidden</title></head>
<body>
<center><h1>403 Forbidden</h1></center>
</body>
</html>

AWSマネジメントコンソールで確認

AWSコンソールの 「WAF & Shield」 > 「保護パック (ウェブ ACL)」 を開き、作成された WebACL をクリックします。
スクリーンショット 2025-12-20 23.45.29

「ダッシュボード、ログ、サンプルリクエストを表示」タブで確認すると、許可されたリクエスト(Allow)とブロックされたリクエスト(Block)の数がグラフで視覚的に確認できます。
スクリーンショット 2025-12-20 23.46.02
スクリーンショット 2025-12-20 23.43.39

「一致する文字列」の欄に APIキーの値「my-api-key」 が表示されています。 WAF もリクエストの中身を検査するために「比較対象の文字列」を保持する必要があるため、設定画面上では平文となります。
スクリーンショット 2025-12-20 23.48.05

おわりに

今回は、API Gatewayを使わずに ALB + Lambda 構成でAPIキー認証を実装する方法を、CDK を用いて解説しました。ALBリスナールール と AWS WAF の2つのアプローチを試してみましたが、それぞれに特徴があります。

ALBリスナールール
シンプルで追加コストなし。ヘッダーベースの認証には十分

AWS WAF
より高度なセキュリティ機能(レート制限、IP制限など)を追加可能。ただし別途WAFの料金が発生

どちらの方法も、SSM Parameter Store から取得したAPIキーは設定画面上で平文表示される点に注意が必要です。より厳格なセキュリティが求められる場合は、Lambda Authorizer を使った認証や、Cognitoとの連携なども検討してください。

今回の実装が、皆さんの参考になれば幸いです。

この記事をシェアする

FacebookHatena blogX

関連記事