ZyVOP Logo
Content That Connects
SeriesAI NewsCategoriesTags
ZyVOP Logo
Content That Connects

Empowering developers and creators with cutting-edge insights, comprehensive tutorials, and innovative solutions for the digital future.

Content

  • Tags
  • Write Article
  • Newsletter

Company

  • About Us
  • Contact

Connect

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • DMCA Policy
  • Code of Conduct

© 2026 ZyVOP. Crafted with care for the developer community.

Made with ❤️ by the ZyVOP team
All systems operational
HomeBuilding a Zero-Trust Internal API on AWS
👍1

Building a Zero-Trust Internal API on AWS

A working AWS pattern where every request has to prove its identity, and network location earns nothing on its own.

#AWS#Zero Trust#API Gateway#IAM#DynamoDB#Lambda#EC2#Terraform#Python#Node.js
Tomson Alex
Tomson Alex

Senior Developer

July 26, 2026
6 min read
4 views
Building a Zero-Trust Internal API on AWS

Most internal APIs check one thing before letting a request through: whether it's coming from inside the network.

VPN connected, on the office Wi-Fi, sitting inside the VPC. Any of those, and the internal wiki loads, the admin panel opens, the service-to-service calls go through without a second look. Network location becomes a stand-in for identity, and that substitution is where most lateral-movement breaches start.

The 2022 Uber breach is the textbook case. An attacker bought a contractor's stolen password, then buried the contractor's phone in MFA push notifications until one got approved. That single approval handed over VPN access, and from inside the network the attacker found hardcoded credentials in a PowerShell script and moved into Slack, internal tools, and cloud infrastructure.

Nobody broke encryption or found a zero-day. Somebody just got trusted, once, and the network did the rest.

Google reached the same conclusion internally back in 2014, when it started publishing the BeyondCorp papers describing how it moved every employee off VPN-based trust and onto per-request identity checks. NIST formalized the same idea for the rest of the industry in August 2020 with SP 800-207, which boils down to one sentence: stop granting trust based on network location, and start granting it based on verified identity, for every single request.

That's a clean sentence to say and a fuzzy one to build. So here's a working version on AWS, small enough to read in one sitting and opinionated enough to actually enforce something.

The three rules this build follows

Before touching any AWS console, it helps to pin down what "zero trust" cashes out to for a backend engineer, because the phrase gets used loosely:

  1. Identity over location. A request from inside the VPC gets zero extra credit. It has to prove who it is the same way a request from the public internet would.

  2. Least privilege, scoped to one resource. Every IAM role gets the narrowest permission that lets it do its one job, not "read DynamoDB," but "read this one table."

  3. No standing secrets to steal. No SSH keys sitting in ~/.ssh, no long-lived API keys in a config file. Credentials get issued short-lived and scoped, or not at all.

Everything below is just those three rules turned into Terraform and IAM policy.

What we're protecting

Take a SaaS company's internal entitlement service, the system that tells other services which unreleased features an enterprise account has been unlocked for early access. Leak that table and you leak next quarter's roadmap to anyone paying attention to a competitor's account.

sequenceDiagram
    participant Client as EC2 client<br/>(instance role only)
    participant GW as API Gateway<br/>(AWS_IAM auth)
    participant Fn as Lambda<br/>(read-only role)
    participant DB as DynamoDB<br/>(encrypted at rest)

    Client->>Client: Sign request with SigV4<br/>using temporary instance credentials
    Client->>GW: GET /entitlements/acct_4821 (signed)
    GW->>GW: Verify signature + execute-api:Invoke permission
    GW->>Fn: Invoke (only if signature valid)
    Fn->>DB: GetItem (read-only role)
    DB-->>Fn: item
    Fn-->>GW: 200 + entitlement data
    GW-->>Client: 200 + entitlement data

    Note over Client,GW: Same client, same network,<br/>no signature → 403 at the gateway.<br/>Lambda never runs.

Storing the data

DynamoDB encrypts every table at rest by default, using an AWS-owned key, and you can't turn that off. That's been true for every table since 2018, and AWS provides it whether or not the rest of this design bothers with zero trust.

resource "aws_dynamodb_table" "entitlements" {
  name         = "account-entitlements"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "account_id"

  attribute {
    name = "account_id"
    type = "S"
  }

  point_in_time_recovery {
    enabled = true
  }
  # server_side_encryption block omitted on purpose.
  # The AWS owned key applies automatically and can't be disabled.
}

A Lambda that can only read

This is where least privilege stops being a slogan. The Lambda's execution role gets one permission, dynamodb:GetItem, scoped to one table ARN. It can't PutItem, can't DeleteItem, can't touch any other table in the account.

import json
import boto3

TABLE_NAME = "account-entitlements"
dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table(TABLE_NAME)


def handler(event, context):
    account_id = event.get("pathParameters", {}).get("accountId")
    if not account_id:
        return _response(400, {"error": "accountId is required"})

    result = table.get_item(Key={"account_id": account_id})
    item = result.get("Item")

    if not item:
        return _response(404, {"error": "no entitlement record for this account"})

    return _response(200, item)


def _response(status_code, body):
    return {
        "statusCode": status_code,
        "headers": {"Content-Type": "application/json"},
        "body": json.dumps(body),
    }

The role that ships with it:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadEntitlementsOnly",
      "Effect": "Allow",
      "Action": ["dynamodb:GetItem"],
      "Resource": "arn:aws:dynamodb:us-east-1:111122223333:table/account-entitlements"
    }
  ]
}

If this function ever gets hijacked through a dependency vulnerability, the attacker inherits a role that can look at one table and change nothing else in the account.

Locking the front door with IAM auth

Flip the method's authorizationType from NONE to AWS_IAM, and every request now has to arrive with an AWS Signature Version 4 signature or it never reaches the Lambda:

resource "aws_api_gateway_method" "get_entitlement" {
  rest_api_id   = aws_api_gateway_rest_api.entitlements.id
  resource_id   = aws_api_gateway_resource.account_id.id
  http_method   = "GET"
  authorization = "AWS_IAM"
}

(The aws_api_gateway_integration, aws_api_gateway_deployment, and aws_api_gateway_stage resources around this are standard AWS_PROXY wiring, identical whether or not you turn on IAM auth, so they're skipped here for length. AWS's permissions docs cover that plumbing in full.)

Unauthenticated traffic hits a 403 at the gateway itself. The Lambda doesn't run, doesn't spend a millisecond of compute on a request with no proof of identity behind it.

An EC2 client with an identity instead of a key

The client machine gets a VPC for isolation, not for trust. Its IAM role is scoped to invoke one method on one API:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "InvokeEntitlementsApiOnly",
      "Effect": "Allow",
      "Action": ["execute-api:Invoke"],
      "Resource": "arn:aws:execute-api:us-east-1:111122223333:a1b2c3d4e5/prod/GET/entitlements/*"
    }
  ]
}

No SSH key pair gets attached at launch. For the rare case someone needs a shell on the box, EC2 Instance Connect pushes a one-time public key into the instance metadata that's valid for 60 seconds, authorized entirely through IAM. Nothing to leak, nothing sitting on disk to steal months later.

Same machine, same network, two different outcomes

This is the part worth actually running. First, a signed request using the instance's temporary credentials:

import boto3
import requests
from botocore.auth import SigV4Auth
from botocore.awsrequest import AWSRequest

REGION = "us-east-1"
API_URL = "https://a1b2c3d4e5.execute-api.us-east-1.amazonaws.com/prod/entitlements/acct_4821"


def signed_get(url):
    session = boto3.Session()
    creds = session.get_credentials().get_frozen_credentials()

    request = AWSRequest(method="GET", url=url)
    SigV4Auth(creds, "execute-api", REGION).add_auth(request)

    return requests.get(url, headers=dict(request.headers))


if __name__ == "__main__":
    signed = signed_get(API_URL)
    print("Signed request:", signed.status_code, signed.text)

    unsigned = requests.get(API_URL)
    print("Unsigned request:", unsigned.status_code, unsigned.text)

Run it, and the signed call comes back 200 with the entitlement record. The plain requests.get() right below it, from the same process, same instance, same VPC, comes back 403 before the Lambda ever wakes up. boto3 pulls the role's temporary credentials automatically, nothing hardcoded, nothing to rotate by hand.

If your team runs on Node.js instead, the same signing pattern uses @aws-sdk/signature-v4:

import { SignatureV4 } from "@aws-sdk/signature-v4";
import { HttpRequest } from "@aws-sdk/protocol-http";
import { Sha256 } from "@aws-crypto/sha256-js";
import { defaultProvider } from "@aws-sdk/credential-provider-node";

const REGION = "us-east-1";
const HOST = "a1b2c3d4e5.execute-api.us-east-1.amazonaws.com";
const PATH = "/prod/entitlements/acct_4821";

const signer = new SignatureV4({
  credentials: defaultProvider(),
  region: REGION,
  service: "execute-api",
  sha256: Sha256,
});

async function signedGet() {
  const request = new HttpRequest({
    method: "GET",
    hostname: HOST,
    path: PATH,
    headers: { host: HOST },
  });

  const signed = await signer.sign(request);

  const response = await fetch(`https://${HOST}${PATH}`, {
    method: "GET",
    headers: signed.headers,
  });

  console.log("Signed request:", response.status, await response.text());
}

signedGet();

Same idea, different runtime. The SDK signs the request from whatever credentials it finds (instance role, container role, or environment variables), and the gateway either recognizes the signature or it doesn't.

Taking the API off the public internet entirely

IAM auth stops anonymous requests. It doesn't stop someone from finding the public endpoint URL and hammering it with credential-stuffing attempts. The next step drops the API off the public internet entirely.

Switch the API Gateway endpoint type to PRIVATE, attach an interface VPC endpoint, and lock the resource policy to that one endpoint:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": "*",
      "Action": "execute-api:Invoke",
      "Resource": "arn:aws:execute-api:us-east-1:111122223333:a1b2c3d4e5/*",
      "Condition": {
        "StringEquals": { "aws:SourceVpce": "vpce-0abc123456789def0" }
      }
    },
    {
      "Effect": "Deny",
      "Principal": "*",
      "Action": "execute-api:Invoke",
      "Resource": "arn:aws:execute-api:us-east-1:111122223333:a1b2c3d4e5/*",
      "Condition": {
        "StringNotEquals": { "aws:SourceVpce": "vpce-0abc123456789def0" }
      }
    }
  ]
}

Now a correctly signed request from outside that one VPC endpoint still gets denied. AWS won't let a private API deploy without this resource policy attached, so the restriction can't be skipped by accident.

What this pattern doesn't fix

Worth saying plainly: none of this replaces monitoring. A stolen instance role's temporary credentials still work until they expire, so pair this with CloudTrail alerts on unusual execute-api:Invoke volume from a single principal. It also doesn't check device posture the way a full BeyondCorp-style rollout does, and a real production system usually needs both layers.

Recap, if you're skimming

  • Every request signed with SigV4, checked at the gateway, not the application code.

  • Every IAM role scoped to one resource and one or two actions, never a service-wide wildcard.

  • No SSH keys living on disk; temporary keys or none at all.

  • Network location (VPC, subnet, security group) used for isolation, never treated as proof of identity.

  • The API reachable only through a specific VPC endpoint once the pattern earns its way into production.

None of these pieces is exotic. IAM auth on API Gateway has existed for years, and DynamoDB encryption ships on by default.

The only real shift is a habit: stop asking "is this request inside the network," and start asking "can this request prove who it is." Everything else in this build is just IAM policy enforcing that one question.

Tomson Alex

Tomson Alex

Blogger

Passionate developer sharing knowledge about modern web technologies and best practices.

Comments (0)

Login to post a comment.

Stay Updated

Get the latest articles delivered to your inbox.

We respect your privacy. Unsubscribe anytime.

Related Posts

Arrays Explained from Memory to Big O (With Real Benchmarks)

A common array bug that silently deletes the wrong items, and what it reveals: how arrays actually work in memory, why insert/delete cost what they cost, and real benchmarks in Python and Node.js to prove it.

Read article

Sign in With Google in Node.js — Without Passport

Learn how to implement Google Sign-In in Node.js without Passport.js by building the complete OAuth 2.0 flow yourself. This guide covers PKCE, CSRF protection with state, token exchange, refresh tokens, secure session handling, production hardening, and comprehensive testing with plain Node.js.

Read article

Rate Limiting Alone Won't Stop a Patient Attacker

IP-based throttling and account-based lockout solve different problems and neither substitutes for the other. Built and tested in NestJS: the exact response shapes @nestjs/throttler returns, a TTL interaction that silently re-locks an account the moment it unlocks, and why an email needs to be normalized before it becomes a Redis key.

Read article

Background Jobs in NestJS with BullMQ: A Complete Walkthrough

A from-scratch background job pipeline in NestJS using BullMQ and Redis, demonstrated on an async LLM draft-generation endpoint — covering retry/backoff configuration, the off-by-one in BullMQ's attemptsMade, durable status records, and the idempotency behavior of duplicate job IDs.

Read article

Linear Algebra Essentials: The Math Every Model in This Series Has Been Hiding

Every prediction this series has made was a dot product wearing a library's clothing. Here's the linear algebra underneath, verified against sklearn's own numbers.

Read article

Popular Tags

#.env.example Node.js#0x profiling#10x faster python scraper tutorial#12-factor#2026#2FA#@nestjs/throttler#AI#AI Anxiety#AI Backend