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:
- API Gateway and its JWT authorizer are controlled exclusively by the platform team. Application teams cannot weaken or bypass authentication.
- VPC placement and security group rules are defined by the platform team. Lambda functions from the application layer land inside boundaries they cannot move.
- 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
| Resource | Purpose |
|---|---|
| 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 tables | Provides network isolation for Lambda functions |
| Security groups | Allows inbound connections from API Gateway only |
| IAM permission boundary policy | Caps 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 entries | Publishes 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:
template.yaml– AWS SAM template defining the Lambda function, its IAM role, and the API Gateway integration..github/workflows/deploy.yml– GitHub Actions workflow that assumes an IAM role via OIDC (no long-lived secrets), builds, and deploys.- 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:PermissionsBoundarymust 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:UpdateFunctionConfigurations3: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
| Concern | Approach 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 Lambda | Low | High |
| Speed to onboard new Lambda | Fast | Slower |
| Blast radius if deployer creds are stolen | Medium (bounded by boundary) | Very low (code update only) |
| Suitable for | Trusted internal teams | High-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.