Zero-Trust Security

Implementing zero-trust on AWS with encryption, secrets management, and security services

Last generated

Lesson 20 of 31 available15 practice questions

SPACED REPETITION Β· 15 practice questions

Make this lesson stick.

Try 3 questions now. No account needed. Sample answers aren't saved.

Zero-Trust Security

A request arrives from a private subnet using a valid credential. Should it be allowed to read another customer's report? Neither the subnet nor successful authentication answers that question. The application must authorize the specific operation on the specific data, and the surrounding controls must limit what a compromised component can reach.

Zero trust removes implicit trust based solely on network location or ownership. It combines identity, device/workload context, least privilege and protected communications with monitoring and recovery. It does not require a new human login for every packet, and it does not make network controls obsolete. AWS zero trust, NIST architecture.

This lesson develops four applications: a segmented application with authenticated API access, a scoped cross-account session, controlled outbound inspection and Verified Access for an internal web application. Each example states the surrounding resources it assumes. Together they teach decisions to verify, not a single turnkey security deployment.

1. Define the access decision before choosing a service

Distinguish authentication (which identity presented acceptable evidence), authorization (which action that identity may perform) and transport protection (how the communication is protected). An encrypted request can still request unauthorized data; a valid JWT can still belong to the wrong tenant.

Use IAM Identity Center or another configured federation path for workforce access, workload IAM roles for AWS API calls, and an appropriate application identity provider for customers. Temporary STS credentials expire, but remain usable within their effective permissions while valid. A short duration alone does not implement an approval workflow or immediate revocation.

MFA conditions need the right context. aws:MultiFactorAuthPresent is not a universal record of upstream MFA for federated identities. A deny using BoolIfExists with false can also deny requests where the key is absent, including legitimate automation/federation paths. Enforce workforce MFA at the identity provider and design tested claims/session-tag conditions when needed. Do not apply an IAM-user MFA recipe indiscriminately to every role. MFA context.

Least privilege also depends on action-specific conditions. This identity policy allows reads of a designated prefix and writes that explicitly request SSE-S3. The upload-encryption header is deliberately not required for GetObject, which does not send that write header:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::example-application-files/uploads/*"
    },
    {
      "Effect": "Allow",
      "Action": "s3:PutObject",
      "Resource": "arn:aws:s3:::example-application-files/uploads/*",
      "Condition": {"StringEquals": {"s3:x-amz-server-side-encryption": "AES256"}}
    }
  ]
}

Replace the example bucket with the intended resource. The second statement does not grant an upload that omits the header even though S3 provides default encryption for new uploads. It does not enforce the same restriction on grants elsewhere. Prefix access is not per-user ownership; a shared application role still needs application-level authorization or a suitably scoped delegation design. S3 policy condition examples.

For a general-purpose bucket, a separate resource-policy deny can reject insecure transport. The service-principal exception below avoids treating redacted service-to-service network context as a human transport failure; review whether that exception fits the service integrations you use:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Sid": "DenyInsecureNonServiceRequests",
    "Effect": "Deny",
    "Principal": "*",
    "Action": "s3:*",
    "Resource": [
      "arn:aws:s3:::example-application-files",
      "arn:aws:s3:::example-application-files/*"
    ],
    "Condition": {"Bool": {
      "aws:SecureTransport": "false",
      "aws:PrincipalIsAWSService": "false"
    }}
  }]
}

This deny grants nobody access. Encryption at rest, KMS authorization, TLS and application authorization solve different problems. Standard AWS-integrated ACM public certificates and exportable/private certificates have different pricing and deployment contracts; do not assume every certificate is free. Secrets Manager rotation must be configured for a supported target. S3 service-to-service conditions, ACM pricing.

2. Application: separate network paths from API authorization

Consider an ALB, an application tier and PostgreSQL in one prepared VPC. Security groups are stateful controls associated with network interfaces; NACLs are stateless subnet controls. A security-group reference identifies network traffic from interfaces associated with that group under supported routing conditions. It does not authenticate an end user or copy the referenced group's rules. Security-group rules, NACLs.

The following Terraform component uses the AWS provider 6.64 series and an existing vpc_id input. Configure the provider and its credentials separately. It creates only groups and rules; attach each group to its intended ALB, application interfaces or database. Standalone rules avoid circular inline references between the groups.

variable "vpc_id" { type = string }
resource "aws_security_group" "tier" {
  for_each    = toset(["edge", "app", "database"])
  vpc_id      = var.vpc_id
  description = "Reviewed ${each.key} traffic boundary"
}
resource "aws_vpc_security_group_ingress_rule" "viewer" {
  security_group_id = aws_security_group.tier["edge"].id
  ip_protocol = "tcp"
  from_port = 443
  to_port = 443
  cidr_ipv4 = "0.0.0.0/0"
}
resource "aws_vpc_security_group_ingress_rule" "app" {
  security_group_id = aws_security_group.tier["app"].id
  referenced_security_group_id = aws_security_group.tier["edge"].id
  ip_protocol = "tcp"
  from_port = 8443
  to_port = 8443
}
resource "aws_vpc_security_group_egress_rule" "edge_to_app" {
  security_group_id = aws_security_group.tier["edge"].id
  referenced_security_group_id = aws_security_group.tier["app"].id
  ip_protocol = "tcp"
  from_port = 8443
  to_port = 8443
}
resource "aws_vpc_security_group_ingress_rule" "database" {
  security_group_id = aws_security_group.tier["database"].id
  referenced_security_group_id = aws_security_group.tier["app"].id
  ip_protocol = "tcp"
  from_port = 5432
  to_port = 5432
}
resource "aws_vpc_security_group_egress_rule" "app_to_database" {
  security_group_id = aws_security_group.tier["app"].id
  referenced_security_group_id = aws_security_group.tier["database"].id
  ip_protocol = "tcp"
  from_port = 5432
  to_port = 5432
}
viewer -- TCP 443 --> ALB [edge]
                      |
                 TCP 8443
                      v
                application [app]
                      |
                 TCP 5432
                      v
                PostgreSQL [database]

Configure an HTTPS listener and certificate on the ALB, an HTTPS target and matching health check on application port 8443, and PostgreSQL TLS separately. ALB HTTPS target connections are encrypted, but ALB does not validate target certificates as a normal verify-full database client does. If your design requires stronger workload identity, provide a mechanism that meets that requirement. Return traffic for allowed connections is covered by state tracking; no broad database egress rule is added here. ALB target TLS.

The AWS provider removes the new group's default allow-all egress rule. This example therefore omits package-download, telemetry and secret-retrieval paths. Add reviewed endpoint/egress rules for those dependencies before expecting a real workload to boot. Routes, NACLs, name resolution, attachments and all other attached groups also matter. More than one attached group contributes the union of its allows. Do not mix inline and standalone rules for the same group. Provider security-group behavior.

An API Gateway HTTP API is a separate entry-path option. To secure an existing HTTP API integration, this initial-deployment CloudFormation component creates a JWT authorizer and one route. Supply an existing API/integration pair, an issuer and an allowed audience from your identity provider, and a stage with automatic deployment enabled. If the stage instead uses manual deployment, create a new deployment after this route and authorizer exist and associate it with that stage before testing. A previously deployed stage serves its old configuration until the changes are deployed. HTTP API deployments. Ensure that no alternate unprotected route reaches the same sensitive operation.

AWSTemplateFormatVersion: '2010-09-09'
Description: A scoped JWT route on an existing HTTP API
Parameters:
  HttpApiId: {Type: String}
  IntegrationId: {Type: String}
  Issuer: {Type: String}
  Audience: {Type: String}
Resources:
  JwtAuthorizer:
    Type: AWS::ApiGatewayV2::Authorizer
    Properties:
      ApiId: !Ref HttpApiId
      Name: reports-jwt
      AuthorizerType: JWT
      IdentitySource: ['$request.header.Authorization']
      JwtConfiguration:
        Issuer: !Ref Issuer
        Audience: [!Ref Audience]
  ReportRoute:
    Type: AWS::ApiGatewayV2::Route
    Properties:
      ApiId: !Ref HttpApiId
      RouteKey: 'GET /reports/{reportId}'
      AuthorizationType: JWT
      AuthorizerId: !Ref JwtAuthorizer
      AuthorizationScopes: ['reports/read']
      Target: !Join ['/', ['integrations', !Ref IntegrationId]]

API Gateway checks the JWT signature and relevant issuer, audience and time claims, then requires an allowed route scope. If several scopes are configured on a route, at least one is sufficient; do not mistake the list for an all-scopes requirement. The backend still must compare the authenticated subject/tenant with the requested report's authorization rules. CORS is a browser policy, not an authorization barrier. HTTP API JWT behavior.

Test missing, expired, wrong-issuer and wrong-audience tokens; a token missing the scope; a permitted caller; and an authenticated caller requesting someone else's report. Confirm both entry-path and backend behavior. For HTTP API monitoring the metric is 4xx in AWS/ApiGateway, not the REST API spelling 4XXError. It counts client errors generally, not only forbidden requests or attackers. Use access logs and a request denominator for more specific analysis. HTTP API metrics.

3. Application: delegate one report read across accounts

Cross-account access has two sides. The caller needs permission to assume the target role, and the target role's trust policy must accept that caller and context. The role's permissions and session restrictions govern subsequent operations. A trust-policy source-IP condition limits the role-assumption request; it does not automatically restrict every use of the issued credentials. AssumeRole.

For an internal automation role, use the exact approved source-role ARN in the target trust policy. This illustrative policy belongs to the target role; substitute the intended source account/role:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": {"AWS": "arn:aws:iam::111111111111:role/ReviewedReportJob"},
    "Action": "sts:AssumeRole"
  }]
}

The source role receives sts:AssumeRole only on the target role ARN. The target role receives the required object-read permission; bucket and KMS policies must also permit the applicable path. No SCP or permissions boundary may deny it. External IDs address confused-deputy risk in appropriate third-party delegation; they are not secrets or a mandatory MFA replacement for every cross-account relationship. Third-party delegation.

This function assumes the prepared target role for one hour, adds a session policy for one object, and returns an S3 client for that session. Its manually supplied credentials will expire; a long-running process needs a supported refreshable credential-provider design.

import boto3
import json

def report_client(sts, role_arn, bucket, key, region):
    policy = {"Version": "2012-10-17", "Statement": [{
        "Effect": "Allow", "Action": "s3:GetObject",
        "Resource": f"arn:aws:s3:::{bucket}/{key}"
    }]}
    response = sts.assume_role(
        RoleArn=role_arn, RoleSessionName="reviewed-report-read",
        DurationSeconds=3600, Policy=json.dumps(policy)
    )
    c = response["Credentials"]
    return boto3.client(
        "s3", region_name=region,
        aws_access_key_id=c["AccessKeyId"],
        aws_secret_access_key=c["SecretAccessKey"],
        aws_session_token=c["SessionToken"]
    )

Inputs are trusted configuration, not arbitrary user-controlled bucket/key values; reject IAM wildcard characters if the object name is supplied dynamically. The example assumes SSE-S3 and no resource policy granting additional access directly to a role-session principal. Session policies restrict the role's identity-policy permissions; they cannot add missing permissions. Resource-based grants to a session principal require separate analysis rather than assuming this policy is a universal ceiling. Session policy evaluation.

3600 seconds is one hour. Ordinary role sessions can request from 15 minutes up to the configured role maximum, no more than 12 hours; API/CLI role chaining is capped at one hour. Requesting more than the applicable maximum fails. This time limit reduces credential lifetime but supplies neither just-in-time approval nor per-object business authorization by itself.

Read the intended object, attempt another object and a write, and confirm the full policy set produces the expected results. Keep credentials and object contents out of logs. For a PostgreSQL connection, use a trusted credential source and a maintained CA bundle with sslmode="verify-full"; that setting verifies the server certificate and hostname as well as requiring TLS. It does not configure the server to reject other clients' insecure connections. RDS PostgreSQL TLS.

4. Application: inspect a deliberately routed outbound path

Private connectivity does not itself authorize an operation. Interface endpoints use AWS PrivateLink for supported services; S3/DynamoDB gateway endpoints are a distinct mechanism. Endpoint policies add a control layer and do not replace IAM/resource policies. A compromised workload can misuse a permitted endpoint or allowed destination. VPC endpoints.

Network Firewall only inspects traffic routed through its endpoints. For a same-AZ IPv4 internet-egress design, plan both directions through the same stateful inspection path:

workload private subnet
        |
        v
firewall endpoint in its AZ
        |
        v
public NAT gateway --> internet gateway --> destination

return: destination --> IGW/NAT --> same firewall endpoint --> workload

The following template creates an inspection policy component, not endpoints or routes. It selects one strict-order domain allowlist for HTTP Host and TLS SNI and a stateful established-traffic default. Attach it to a deliberately routed firewall only after reviewing protocol behavior. The laboratory destination names are reserved examples; replace them with actual approved endpoints for connectivity tests.

AWSTemplateFormatVersion: '2010-09-09'
Description: Strict-order domain inspection policy for a prepared firewall path
Parameters:
  WorkloadCidr: {Type: String, Default: '10.60.0.0/16'}
Resources:
  ApprovedDomains:
    Type: AWS::NetworkFirewall::RuleGroup
    Properties:
      RuleGroupName: reviewed-http-tls-destinations
      Type: STATEFUL
      Capacity: 100
      RuleGroup:
        StatefulRuleOptions: {RuleOrder: STRICT_ORDER}
        RuleVariables:
          IPSets:
            HOME_NET: {Definition: [!Ref WorkloadCidr]}
        RulesSource:
          RulesSourceList:
            GeneratedRulesType: ALLOWLIST
            TargetTypes: [HTTP_HOST, TLS_SNI]
            Targets: ['api.example.com', 'updates.example.com']
  InspectionPolicy:
    Type: AWS::NetworkFirewall::FirewallPolicy
    Properties:
      FirewallPolicyName: reviewed-outbound-inspection
      FirewallPolicy:
        StatelessDefaultActions: ['aws:forward_to_sfe']
        StatelessFragmentDefaultActions: ['aws:forward_to_sfe']
        StatefulEngineOptions: {RuleOrder: STRICT_ORDER}
        StatefulDefaultActions: ['aws:drop_established']
        StatefulRuleGroupReferences:
          - ResourceArn: !Ref ApprovedDomains
            Priority: 1
Outputs:
  FirewallPolicyArn: {Value: !Ref InspectionPolicy}

An established-traffic default permits the TCP handshake to progress so application-layer matching can occur; dropping every packet immediately would prevent domain rules seeing the hostname. Test segmented TLS ClientHello messages and HTTP headers too: this default can drop a packet before application data spanning several packets is available. Review application-layer drop rules or the documented application-established defaults if the workload requires that behavior. Other protocols need deliberate rules and tests. With default action ordering, pass takes precedence over drop; placing a deny group visually β€œabove” an allow group is not a reliable override. Strict-order groups and their policy must agree on ordering. Evaluation order.

Domain inspection uses the HTTP Host header or TLS SNI, not a fresh DNS lookup or proof of application identity. An exact name differs from a leading-dot domain wildcard. Broad .amazonaws.com access could include destinations controlled by another AWS customer. Explicitly test header/SNI manipulation, absent or encrypted hostname information, QUIC and protocols outside the intended HTTP/TLS path; do not assume they receive equivalent inspection. Centralized inspection must include the intended workload networks in HOME_NET, not only the firewall VPC. Domain rule semantics.

Enable actual firewall alert/flow log destinations with appropriate permissions and retention. Network Firewall wraps its engine event under event; an alert parser should read that nested object. This generator follows pagination and emits selected metadata rather than printing whole records:

import json

def firewall_alerts(logs, log_group, start_ms, end_ms):
    paginator = logs.get_paginator("filter_log_events")
    for page in paginator.paginate(
        logGroupName=log_group, startTime=start_ms, endTime=end_ms,
        filterPattern='{ $.event.event_type = "alert" }'
    ):
        for item in page.get("events", []):
            engine = json.loads(item["message"]).get("event", {})
            if engine.get("event_type") != "alert":
                continue
            alert = engine.get("alert", {})
            yield {
                "timestamp": item["timestamp"],
                "signature": alert.get("signature"),
                "action": alert.get("action"),
                "source": engine.get("src_ip"),
                "destination": engine.get("dest_ip")
            }

The event type alert is not the same field as the engine's reported action. Investigate log evidence together with route tables, firewall policy and observed client outcome. A silent log query could mean no matching traffic, broken delivery or the wrong time window. Firewall log structure.

5. Application: gate an internal web application with Verified Access

Verified Access evaluates policies using configured user and device trust data. Policies are written in Cedar, not IAM JSON. A policy's context keys come from the trust providers' configured policy reference names; fabricated verified-access:* condition keys do not create identity or device checks. Verified Access policies.

For this example, configure IAM Identity Center as the user trust provider with policy reference name workforce, and CrowdStrike as a device trust provider with name device. Set up the actual user/device integrations, browser extension and required native host, and inspect the documented trust-data schema. The example assumes the configured provider supplies an integer assessment.overall value; a score threshold is an organization policy choice, not a universal proof that a device is safe. Identity Center context, device context and setup.

Build and verify this dependency chain:

  1. Create or select the trust providers and attach them to the intended Verified Access instance.
  2. Create a Verified Access group on that instance and configure its reviewed group policy.
  3. Create the web endpoint in the group, targeting a prepared internal load balancer and its listener/subnets. Supply a matching ACM domain certificate and appropriate endpoint security groups.
  4. Restrict the application load balancer to the intended endpoint path; remove alternate public/direct routes that bypass the decision. Configure DNS from the application domain to the endpoint's generated domain.
  5. Enable access logging and test both allowed and denied user/device cases before opening the application to its intended users.

Creating a trust provider without attaching it, or creating an endpoint with an invented group ID, does not establish this chain. Credentials for an OIDC client belong in protected configuration, not shell-history examples. Setup sequence.

Save this as group-policy.cedar, replacing the illustrative group UUID with the actual immutable Identity Center group ID. It requires group membership, a verified email attribute and the chosen device assessment. The presence checks prevent missing nested attributes from being treated as successful evidence.

permit(principal, action, resource)
when {
    context has workforce &&
    context.workforce has user &&
    context.workforce.user has email &&
    context.workforce.user.email has verified &&
    context.workforce.user.email.verified == true &&
    context.workforce has groups &&
    context.workforce.groups has "11111111-2222-3333-4444-555555555555" &&
    context has device &&
    context.device has assessment &&
    context.device.assessment has overall &&
    context.device.assessment.overall >= 70
};

Apply it to the existing intended group using an authenticated EC2 client. group_id is the returned real group ID, and policy_text is the file's text:

def install_group_policy(ec2, group_id, policy_text):
    return ec2.modify_verified_access_group_policy(
        VerifiedAccessGroupId=group_id,
        PolicyEnabled=True,
        PolicyDocument=policy_text
    )

If both group and endpoint policies are defined, both must allow the request. Within a policy document, a matching forbid overrides a permit; without a matching permit, access is denied. An additional broad permit in the same document can change the intended decision, so review the whole document. Syntax acceptance does not prove that context names, types or values match real provider data. Evaluation behavior.

Test scores 69 and 70 with all other attributes valid, a missing device context, wrong group, unverified email and a legitimately permitted user. Then test attempted direct access to the underlying application. Review actual refreshed trust data and session behavior rather than assuming a directory/device change instantaneously terminates every connection. Application authorization remains necessary after this front-door decision.

6. Investigate signals without inventing guarantees

CloudTrail records supported activity according to collection settings. GuardDuty analyzes its supported data sources and configured protection features; it does not require you to create your own VPC Flow Logs simply to supply its foundational flow telemetry. Security Hub/CSPM and Detective support prioritization and investigation, not automatic proof of compromise. GuardDuty data sources.

For an Athena engine 3 table using CloudTrail's documented schema, eventtime is a string. Parse it before a time comparison. This query computes error counts and a denominator for recorded events in the last seven days:

WITH recent AS (
  SELECT coalesce(useridentity.arn, useridentity.principalid, 'unknown') AS identity,
         errorcode
  FROM cloudtrail_logs
  WHERE try(from_iso8601_timestamp(eventtime)) >= current_timestamp - interval '7' day
    AND try(from_iso8601_timestamp(eventtime)) < current_timestamp
)
SELECT identity,
       count(*) AS recorded_events,
       count_if(errorcode IS NOT NULL AND errorcode <> '') AS error_events,
       100.0 * count_if(errorcode IS NOT NULL AND errorcode <> '') / count(*) AS error_percent
FROM recent
GROUP BY identity
HAVING count_if(errorcode IS NOT NULL AND errorcode <> '') > 100
ORDER BY error_events DESC

Add partition predicates appropriate to the actual table to control scan cost. This compares recorded event populations, not every application request; selection gaps and duplicates can affect interpretation. A principal with 120 errors among 12,000 events has a 1% error fraction, while 110 among 200 has 55%. Neither result alone establishes malicious activity. CloudTrail Athena examples.

If credentials may be compromised, preserve evidence, identify the credential type and affected permissions, and follow an incident procedure for containment and session revocation. Changing a long-lived key does not describe the recovery of an already-issued role session. Blindly terminating the suspected instance can destroy evidence or disrupt service while leaving other access paths intact. Credential remediation.

Finish by rehearsing one authorized request, one cross-tenant denial, one expired session, one prohibited egress path and one user/device revocation. Record which control makes each decision, which evidence confirms it and which assumptions remain outside the example. That is a more useful security result than declaring the architecture β€œzero trust” after enabling several services.