Two-Layered AWS Architecture: API Gateway with Okta JWT and Lambda Functions

READER BEWARE: THE FOLLOWING WRITTEN ENTIRELY BY AI WITHOUT HUMAN EDITING.

Introduction

Building secure, scalable serverless APIs on AWS requires careful thought about who owns what. When multiple teams contribute Lambda functions behind a shared API Gateway, it is tempting to let each team manage the entire stack end to end. That approach produces a tangled mess of overlapping permissions, inconsistent security controls, and accidental sprawl.

A cleaner model splits the responsibility into two distinct layers, each living in its own git repository and deployed by its own pipeline:

  • Layer 1 – The Foundation: A Terraform repository owned by a platform or infrastructure team. It provisions AWS API Gateway, configures JWT-based authentication with Okta, defines the VPC and networking boundaries, and locks down the IAM permissions available to the next layer.
  • Layer 2 – The Application: One or more repositories owned by application teams. Each repository contains a single Lambda function (or a small group of related functions), uses AWS SAM for packaging and deployment, and ships via GitHub Actions.

This post walks through the architecture, examines two permission philosophies for the Lambda deployment layer, and provides a working proof-of-concept (POC) for each.


Architecture Overview

                         ┌─────────────────────────────────────┐
                         │          LAYER 1 (Terraform)        │
                         │   Owned by: Platform / Infra Team   │
                         │                                     │
 Client Request          │  ┌─────────────┐  ┌────────────┐  │
 ──────────────────────► │  │ API Gateway │  │   Okta JWT │  │
                         │  │  (REST/HTTP)│◄─┤ Authorizer │  │
                         │  └──────┬──────┘  └────────────┘  │
                         │         │                           │
                         │  ┌──────▼──────────────────────┐  │
                         │  │  VPC / Private Subnets       │  │
                         │  │  Security Groups (ingress     │  │
                         │  │  from APIGW only)             │  │
                         │  │  IAM Permission Boundary      │  │
                         │  └──────┬──────────────────────┘  │
                         └─────────┼───────────────────────────┘
                                   │
                         ┌─────────▼───────────────────────────┐
                         │         LAYER 2 (AWS SAM)           │
                         │   Owned by: Application Team(s)     │
                         │                                     │
                         │  ┌──────────────┐  ┌────────────┐  │
                         │  │  Lambda Fn A │  │ Lambda Fn B│  │
                         │  │  (service-a) │  │ (service-b)│  │
                         │  └──────────────┘  └────────────┘  │
                         │   Deployed by GitHub Actions        │
                         └─────────────────────────────────────┘

Key design principles:

  1. API Gateway and its JWT authorizer are controlled exclusively by the platform team. Application teams cannot weaken or bypass authentication.
  2. VPC placement and security group rules are defined by the platform team. Lambda functions from the application layer land inside boundaries they cannot move.
  3. IAM permission boundaries are attached by the platform team. Application teams deploy within those boundaries, so even if a GitHub Actions secret is compromised the blast radius is limited.

Layer 1 – The Foundation

What it manages

ResourcePurpose
API Gateway (HTTP API v2)Public entry point; routes requests to Lambda integrations
JWT Authorizer (Okta)Validates JWTs on every inbound request
VPC, subnets, route tablesProvides network isolation for Lambda functions
Security groupsAllows inbound connections from API Gateway only
IAM permission boundary policyCaps the effective permissions of any IAM role created by the application layer
Lambda execution role template (optional)Pre-creates roles with least-privilege; app teams reference them by ARN
SSM Parameter Store entriesPublishes VPC IDs, subnet IDs, SG IDs, and the APIGW execution ARN for app teams to consume

Okta JWT Authorizer

AWS API Gateway HTTP API supports JWT authorizers natively. You point it at the Okta authorization server’s JWKS endpoint, and Gateway validates each token before a Lambda is invoked.

resource "aws_apigatewayv2_authorizer" "okta_jwt" {
  api_id           = aws_apigatewayv2_api.main.id
  authorizer_type  = "JWT"
  identity_sources = ["$request.header.Authorization"]
  name             = "okta-jwt"

  jwt_configuration {
    audience = [var.okta_audience]
    issuer   = var.okta_issuer  # e.g. https://dev-12345.okta.com/oauth2/default
  }
}

No Lambda authorizer function is required; Gateway handles validation entirely in the data plane. This removes a latency hop and eliminates a custom codebase to maintain.

VPC and Networking

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.0"

  name = "platform-vpc"
  cidr = "10.0.0.0/16"

  azs             = ["us-east-1a", "us-east-1b"]
  private_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
  public_subnets  = ["10.0.101.0/24", "10.0.102.0/24"]

  enable_nat_gateway = true
  single_nat_gateway = true
}

resource "aws_security_group" "lambda_sg" {
  name        = "lambda-functions-sg"
  description = "Allow inbound from API Gateway VPC endpoint only"
  vpc_id      = module.vpc.vpc_id

  ingress {
    from_port       = 443
    to_port         = 443
    protocol        = "tcp"
    security_groups = [aws_security_group.apigw_vpce_sg.id]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

IAM Permission Boundary

The permission boundary is the key security control that constrains everything the application layer can do, regardless of what IAM policies it creates.

resource "aws_iam_policy" "lambda_permission_boundary" {
  name        = "LambdaPermissionBoundary"
  description = "Maximum permissions any Lambda execution role may have"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "AllowLambdaBasics"
        Effect = "Allow"
        Action = [
          "logs:CreateLogGroup",
          "logs:CreateLogStream",
          "logs:PutLogEvents",
          "xray:PutTraceSegments",
          "xray:PutTelemetryRecords"
        ]
        Resource = "*"
      },
      {
        Sid    = "AllowVpcNetworking"
        Effect = "Allow"
        Action = [
          "ec2:CreateNetworkInterface",
          "ec2:DescribeNetworkInterfaces",
          "ec2:DeleteNetworkInterface"
        ]
        Resource = "*"
      },
      {
        Sid    = "AllowSpecificServices"
        Effect = "Allow"
        Action = [
          "dynamodb:*",
          "s3:GetObject",
          "s3:PutObject",
          "secretsmanager:GetSecretValue"
        ]
        Resource = "*"
        Condition = {
          StringEquals = {
            "aws:RequestedRegion" = var.aws_region
          }
        }
      },
      {
        Sid    = "DenyEscape"
        Effect = "Deny"
        Action = [
          "iam:*",
          "organizations:*",
          "account:*"
        ]
        Resource = "*"
      }
    ]
  })
}

SSM Parameters (published for app teams)

resource "aws_ssm_parameter" "vpc_id" {
  name  = "/platform/network/vpc-id"
  type  = "String"
  value = module.vpc.vpc_id
}

resource "aws_ssm_parameter" "private_subnet_ids" {
  name  = "/platform/network/private-subnet-ids"
  type  = "StringList"
  value = join(",", module.vpc.private_subnets)
}

resource "aws_ssm_parameter" "lambda_security_group_id" {
  name  = "/platform/network/lambda-sg-id"
  type  = "String"
  value = aws_security_group.lambda_sg.id
}

resource "aws_ssm_parameter" "permission_boundary_arn" {
  name  = "/platform/iam/lambda-permission-boundary-arn"
  type  = "String"
  value = aws_iam_policy.lambda_permission_boundary.arn
}

resource "aws_ssm_parameter" "api_id" {
  name  = "/platform/apigw/api-id"
  type  = "String"
  value = aws_apigatewayv2_api.main.id
}

Layer 2 – The Application

Each application repository follows the same pattern:

  1. template.yaml – AWS SAM template defining the Lambda function, its IAM role, and the API Gateway integration.
  2. .github/workflows/deploy.yml – GitHub Actions workflow that assumes an IAM role via OIDC (no long-lived secrets), builds, and deploys.
  3. Application source code.

The application team only decides what their Lambda does, not how it is secured at the network or auth layer.


Division of Responsibility

The most critical design question is: how many IAM permissions does the GitHub Actions deployer have?

The answer determines what the platform team must pre-provision and what the application team is free to manage themselves.

Approach A – High-Permission Deployer

The GitHub Actions role can create and manage IAM roles, security groups, and other infrastructure within the VPC.

Deployer permissions include:

  • iam:CreateRole, iam:AttachRolePolicy, iam:PutRolePolicy (constrained to roles that carry the permission boundary)
  • ec2:AuthorizeSecurityGroupIngress, ec2:RevokeSecurityGroupIngress (constrained to a specific SG)
  • lambda:*, cloudformation:*, s3:* (for SAM deployment)
  • apigatewayv2:CreateIntegration, apigatewayv2:CreateRoute (to wire themselves into the gateway)

What the platform team must still enforce:

  • Require the permission boundary on any role the deployer creates (enforced via an IAM condition: iam:PermissionsBoundary must equal the boundary ARN).
  • SCP or permission boundary on the deployer role itself to prevent boundary removal.

What the platform team can skip:

  • Pre-creating Lambda execution roles (app teams create their own).
  • Pre-wiring API Gateway routes (app teams add their own routes).

Trade-off: Application teams move fast and self-serve. The platform team’s job is to ensure the guard rails (permission boundary enforcement conditions) are airtight. A misconfigured condition could allow privilege escalation.

Approach B – Low-Permission Deployer

The GitHub Actions role can only deploy Lambda code and cannot touch IAM, networking, or API Gateway configuration.

Deployer permissions include:

  • lambda:UpdateFunctionCode, lambda:UpdateFunctionConfiguration
  • s3:PutObject, s3:GetObject (deployment bucket only)
  • cloudformation:DescribeStacks, cloudformation:DescribeChangeSet (read-only, for status checks)

What the platform team must pre-provision:

  • Lambda execution role (with permission boundary attached).
  • Lambda function resource (with VPC config, SG, subnet IDs already set).
  • API Gateway integration and route pointing at that Lambda.
  • CloudWatch Log Group.
  • Any event source mappings or other triggers.

What the platform team can skip:

  • Tight IAM condition logic on the deployer role (it simply cannot do anything harmful).

Trade-off: Onboarding a new Lambda service requires a platform team pull request to Layer 1 Terraform. Application teams have no autonomy over infrastructure shape. This is operationally slower but maximally safe.

Comparison Table

ConcernApproach A (High Permission)Approach B (Low Permission)
App team self-service✅ Full❌ Requires platform PR
Privilege escalation risk⚠️ Mitigated by boundary conditions✅ Not possible
Platform toil per LambdaLowHigh
Speed to onboard new LambdaFastSlower
Blast radius if deployer creds are stolenMedium (bounded by boundary)Very low (code update only)
Suitable forTrusted internal teamsHigh-compliance or contractor environments

POC – Approach A: High-Permission Deployer

This POC shows how a self-sufficient application team deploys a Lambda that automatically wires itself into the platform API Gateway.

Layer 1 Terraform (additions for Approach A)

The platform team creates the deployer IAM role with conditions that enforce the permission boundary on any roles the deployer creates.

# deployer_role.tf
data "aws_iam_policy_document" "github_actions_assume" {
  statement {
    actions = ["sts:AssumeRoleWithWebIdentity"]
    principals {
      type        = "Federated"
      identifiers = [aws_iam_openid_connect_provider.github.arn]
    }
    condition {
      test     = "StringLike"
      variable = "token.actions.githubusercontent.com:sub"
      values   = ["repo:${var.github_org}/*:ref:refs/heads/main"]
    }
  }
}

resource "aws_iam_role" "github_actions_deployer" {
  name               = "GitHubActionsHighPermDeployer"
  assume_role_policy = data.aws_iam_policy_document.github_actions_assume.json
  permissions_boundary = aws_iam_policy.deployer_boundary.arn
}

resource "aws_iam_policy" "deployer_policy" {
  name = "GitHubActionsHighPermDeployerPolicy"
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      # SAM / CloudFormation
      {
        Effect   = "Allow"
        Action   = ["cloudformation:*", "s3:*"]
        Resource = "*"
      },
      # Lambda
      {
        Effect   = "Allow"
        Action   = ["lambda:*"]
        Resource = "*"
      },
      # API Gateway – can create routes and integrations
      {
        Effect   = "Allow"
        Action   = ["apigatewayv2:CreateIntegration", "apigatewayv2:CreateRoute",
                    "apigatewayv2:DeleteIntegration", "apigatewayv2:DeleteRoute",
                    "apigatewayv2:GetIntegration", "apigatewayv2:GetRoute",
                    "apigatewayv2:UpdateIntegration", "apigatewayv2:UpdateRoute"]
        Resource = "arn:aws:apigateway:${var.aws_region}::/apis/${aws_apigatewayv2_api.main.id}/*"
      },
      # IAM – can create roles, but MUST attach the permission boundary
      {
        Effect = "Allow"
        Action = ["iam:CreateRole", "iam:DeleteRole", "iam:AttachRolePolicy",
                  "iam:DetachRolePolicy", "iam:PutRolePolicy", "iam:DeleteRolePolicy",
                  "iam:PassRole", "iam:GetRole", "iam:TagRole"]
        Resource = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/app-*"
        Condition = {
          StringEquals = {
            "iam:PermissionsBoundary" = aws_iam_policy.lambda_permission_boundary.arn
          }
        }
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "deployer" {
  role       = aws_iam_role.github_actions_deployer.name
  policy_arn = aws_iam_policy.deployer_policy.arn
}

Application Repository: SAM Template (Approach A)

# template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Service A Lambda – Approach A (self-managed IAM and APIGW wiring)

Parameters:
  ApiId:
    Type: AWS::SSM::Parameter::Value<String>
    Default: /platform/apigw/api-id
  PrivateSubnetIds:
    Type: AWS::SSM::Parameter::Value<List<String>>
    Default: /platform/network/private-subnet-ids
  LambdaSecurityGroupId:
    Type: AWS::SSM::Parameter::Value<String>
    Default: /platform/network/lambda-sg-id
  PermissionBoundaryArn:
    Type: AWS::SSM::Parameter::Value<String>
    Default: /platform/iam/lambda-permission-boundary-arn

Globals:
  Function:
    Timeout: 30
    Runtime: python3.12
    Architectures: [arm64]

Resources:
  ServiceARole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: app-service-a-lambda-role
      PermissionsBoundary: !Ref PermissionBoundaryArn
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole
      Policies:
        - PolicyName: service-a-app-policy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - dynamodb:GetItem
                  - dynamodb:PutItem
                  - dynamodb:UpdateItem
                Resource: !Sub "arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/service-a-*"

  ServiceAFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: service-a
      CodeUri: src/
      Handler: app.handler
      Role: !GetAtt ServiceARole.Arn
      VpcConfig:
        SubnetIds: !Ref PrivateSubnetIds
        SecurityGroupIds:
          - !Ref LambdaSecurityGroupId

  # Wire into API Gateway
  ServiceAIntegration:
    Type: AWS::ApiGatewayV2::Integration
    Properties:
      ApiId: !Ref ApiId
      IntegrationType: AWS_PROXY
      IntegrationUri: !GetAtt ServiceAFunction.Arn
      PayloadFormatVersion: '2.0'

  ServiceARoute:
    Type: AWS::ApiGatewayV2::Route
    Properties:
      ApiId: !Ref ApiId
      RouteKey: "GET /service-a/{proxy+}"
      AuthorizationType: JWT
      AuthorizerId: "{{resolve:ssm:/platform/apigw/authorizer-id}}"
      Target: !Sub "integrations/${ServiceAIntegration}"

  ServiceALambdaPermission:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !GetAtt ServiceAFunction.Arn
      Action: lambda:InvokeFunction
      Principal: apigateway.amazonaws.com
      SourceArn: !Sub "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${ApiId}/*"

GitHub Actions Workflow (Approach A)

# .github/workflows/deploy.yml
name: Deploy Service A (Approach A)

on:
  push:
    branches: [main]

permissions:
  id-token: write   # Required for OIDC
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::${{ vars.AWS_ACCOUNT_ID }}:role/GitHubActionsHighPermDeployer
          aws-region: us-east-1

      - uses: aws-actions/setup-sam@v2

      - name: SAM Build
        run: sam build

      - name: SAM Deploy
        run: |
          sam deploy \
            --stack-name service-a \
            --s3-bucket ${{ vars.SAM_ARTIFACTS_BUCKET }} \
            --capabilities CAPABILITY_IAM CAPABILITY_NAMED_IAM \
            --no-confirm-changeset \
            --no-fail-on-empty-changeset \
            --region us-east-1          

POC – Approach B: Low-Permission Deployer

In this approach, the platform team pre-provisions everything. The GitHub Actions deployer can only push new Lambda code.

Layer 1 Terraform (full pre-provisioning for Approach B)

# service_b_infra.tf
# The platform team owns this file. A new service requires a PR to add a block like this.

locals {
  services = {
    "service-b" = {
      route_key = "GET /service-b/{proxy+}"
    }
  }
}

resource "aws_iam_role" "service_b_lambda_role" {
  name                 = "app-service-b-lambda-role"
  permissions_boundary = aws_iam_policy.lambda_permission_boundary.arn

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "lambda.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })

  managed_policy_arns = [
    "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
  ]
}

resource "aws_cloudwatch_log_group" "service_b" {
  name              = "/aws/lambda/service-b"
  retention_in_days = 30
}

resource "aws_lambda_function" "service_b" {
  function_name = "service-b"
  role          = aws_iam_role.service_b_lambda_role.arn
  runtime       = "python3.12"
  handler       = "app.handler"
  architectures = ["arm64"]

  # Placeholder code; real code deployed by application team
  filename         = data.archive_file.placeholder.output_path
  source_code_hash = data.archive_file.placeholder.output_base64sha256

  vpc_config {
    subnet_ids         = module.vpc.private_subnets
    security_group_ids = [aws_security_group.lambda_sg.id]
  }

  depends_on = [aws_cloudwatch_log_group.service_b]
}

data "archive_file" "placeholder" {
  type        = "zip"
  output_path = "/tmp/placeholder.zip"
  source {
    content  = "def handler(event, context): return {'statusCode': 200, 'body': 'placeholder'}"
    filename = "app.py"
  }
}

resource "aws_apigatewayv2_integration" "service_b" {
  api_id             = aws_apigatewayv2_api.main.id
  integration_type   = "AWS_PROXY"
  integration_uri    = aws_lambda_function.service_b.invoke_arn
  payload_format_version = "2.0"
}

resource "aws_apigatewayv2_route" "service_b" {
  api_id    = aws_apigatewayv2_api.main.id
  route_key = "GET /service-b/{proxy+}"

  authorization_type = "JWT"
  authorizer_id      = aws_apigatewayv2_authorizer.okta_jwt.id

  target = "integrations/${aws_apigatewayv2_integration.service_b.id}"
}

resource "aws_lambda_permission" "service_b_apigw" {
  statement_id  = "AllowAPIGWInvoke"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.service_b.function_name
  principal     = "apigateway.amazonaws.com"
  source_arn    = "${aws_apigatewayv2_api.main.execution_arn}/*"
}

# Low-permission deployer role for the application team
resource "aws_iam_role" "github_actions_low_perm_deployer" {
  name               = "GitHubActionsLowPermDeployer-service-b"
  assume_role_policy = data.aws_iam_policy_document.github_actions_assume.json
}

resource "aws_iam_policy" "low_perm_deployer_policy" {
  name = "GitHubActionsLowPermDeployerPolicy-service-b"
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "lambda:UpdateFunctionCode",
          "lambda:UpdateFunctionConfiguration",
          "lambda:GetFunction",
          "lambda:GetFunctionConfiguration",
          "lambda:PublishVersion",
          "lambda:CreateAlias",
          "lambda:UpdateAlias"
        ]
        Resource = aws_lambda_function.service_b.arn
      },
      {
        Effect   = "Allow"
        Action   = ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"]
        Resource = "${aws_s3_bucket.sam_artifacts.arn}/*"
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "low_perm_deployer" {
  role       = aws_iam_role.github_actions_low_perm_deployer.name
  policy_arn = aws_iam_policy.low_perm_deployer_policy.arn
}

Application Repository: SAM Template (Approach B)

In Approach B, SAM is used only to build and package the code. The stack already exists; the workflow updates the function code in-place.

# template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
  Service B Lambda – Approach B (platform-managed infra, code-only deploy)
  NOTE: This template is used ONLY to build/package locally.
  The Lambda function and all infrastructure are pre-created by Layer 1 Terraform.
  The deploy workflow calls lambda:UpdateFunctionCode directly.  

Globals:
  Function:
    Timeout: 30
    Runtime: python3.12
    Architectures: [arm64]

Resources:
  # Declared here for local SAM testing only (sam local invoke / sam local start-api)
  ServiceBFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: service-b
      CodeUri: src/
      Handler: app.handler
      Events:
        Api:
          Type: HttpApi
          Properties:
            Path: /service-b/{proxy+}
            Method: GET

GitHub Actions Workflow (Approach B)

# .github/workflows/deploy.yml
name: Deploy Service B (Approach B)

on:
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::${{ vars.AWS_ACCOUNT_ID }}:role/GitHubActionsLowPermDeployer-service-b
          aws-region: us-east-1

      - uses: aws-actions/setup-sam@v2

      - name: SAM Build
        run: sam build

      - name: Package into zip
        run: |
          cd .aws-sam/build/ServiceBFunction
          zip -r ../../../function.zip .          

      - name: Update Lambda code
        run: |
          aws lambda update-function-code \
            --function-name service-b \
            --zip-file fileb://function.zip \
            --region us-east-1          

      - name: Wait for update
        run: |
          aws lambda wait function-updated \
            --function-name service-b \
            --region us-east-1          

      - name: Publish version
        run: |
          aws lambda publish-version \
            --function-name service-b \
            --region us-east-1          

Making the Choice

Choose Approach A when:

  • Your application teams are experienced with AWS and trusted to operate within guard rails.
  • Teams need to move fast and cannot afford platform bottlenecks for infrastructure changes.
  • You have confidence that IAM permission boundary conditions are implemented correctly and reviewed regularly.
  • You want teams to own their entire service, including infrastructure shape.

Choose Approach B when:

  • You operate in a regulated environment (PCI, HIPAA, FedRAMP) where change control for infrastructure is mandatory.
  • Application teams are external contractors or less-trusted parties.
  • Your threat model includes compromised CI/CD credentials as a primary concern.
  • The platform team has the capacity to process onboarding requests promptly.

Hybrid Option

A common middle ground is to start with Approach B for initial onboarding (platform team provisions the skeleton), then grant teams a slightly elevated deployer role that allows them to adjust Lambda configuration (memory, timeout, environment variables) but not IAM or networking. This is achieved by giving the deployer lambda:UpdateFunctionConfiguration scoped only to their function ARN, while keeping IAM and VPC management locked to the platform team.


Security Considerations

JWT validation happens before Lambda is invoked. API Gateway rejects any request with a missing or invalid Okta JWT at the control plane level. Lambda functions never see unauthenticated traffic. This is a significant operational advantage over Lambda authorizer patterns where a bug in the authorizer code could accidentally permit access.

Permission boundaries are not a substitute for least-privilege. They define the ceiling but not the floor. Application teams should still write minimal IAM policies for their Lambda execution roles. The boundary catches mistakes and malicious escalation, but does not replace good policy hygiene.

OIDC for GitHub Actions eliminates long-lived secrets. Both approaches use GitHub’s OIDC provider to obtain short-lived AWS credentials. There are no AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY values stored as GitHub secrets, which eliminates the most common CI/CD credential theft vector.

VPC does not make Lambda more secure by default. Placing Lambda in a VPC restricts outbound internet access (useful for preventing data exfiltration) and allows access to private resources, but it does not add a meaningful authentication layer. The real security value of VPC placement here is that the security group on the Lambda functions allows inbound connections only from the API Gateway VPC endpoint, preventing direct Lambda invocation bypass.


Conclusion

The two-layer architecture described here cleanly separates platform concerns from application concerns. The platform team owns the authentication boundary, the network boundary, and the IAM ceiling. Application teams own their business logic and, depending on the permission model chosen, varying degrees of their own infrastructure.

Approach A maximizes developer autonomy at the cost of more sophisticated guard rail configuration. Approach B maximizes security at the cost of platform team toil and slower onboarding. Neither is universally better; the right choice depends on your organization’s trust model, compliance requirements, and team maturity.

What both approaches share is that the Okta JWT authorizer, the VPC topology, and the permission boundary are immovable from the application layer’s perspective. That shared foundation is what makes multi-team Lambda deployments safe at scale.