In short

Treat mutual TLS as authentication, then authorize every call. In Istio, set PeerAuthentication to STRICT so plaintext is refused, and add an ALLOW AuthorizationPolicy that names the caller principals, methods and paths each service may use; keep tenant and object checks in the application. For functions, scope who can invoke them and what their execution role can reach separately, and derive each invocation's authority from its own event, never from state left by an earlier one.

  • Istio's default PERMISSIVE mode accepts plaintext: in a test, a pod without a sidecar could call every endpoint until STRICT mode was set.
  • A valid certificate proves the workload, not its permission; an ALLOW policy denies every call it does not list with 403.
  • Istio path rules support a * only at the start or end; use {*} for one segment in the middle, or the rule silently never matches.
  • Lambda polls an SQS queue with the execution role, so who may create the event source mapping is the invocation control for SQS triggers.
  • A reused Lambda process must not carry one caller's tenant or token into the next invocation.

Verify it

  1. Call billing from a pod without a sidecar and expect the connection to fail under STRICT mode.
  2. Run the probe script: reports may export and not check health, support may check health and not export, and every denial is 403.
  3. After any policy change, wait for it to reach the proxies and re-run the probe; in the test a new policy took several seconds to apply.
  4. Run Checkov rule CKV_AWS_364 on the function Terraform and expect every service permission to carry source_arn.
  5. Invoke the function twice in one warm process, the second time without a tenant, and confirm the second message is rejected.
Applies to
  • Istio 1.31.1, sidecar mode, security.istio.io/v1 (tested on Kubernetes 1.37 with kind)
  • AWS Lambda SQS event source mappings and resource-based policies as of 2026-10-10

Service-to-service and serverless identity boundaries. TLS authentication does not authorize every action.

Part of
Workflows: Fix a finding, Review a design

Fix it in your stack

Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

Learning goals and prerequisitesAbout 25 minutes: 7 to read and 18 for the exercise

What you will learn

  • Separate transport identity from method and tenant authorization
  • Diagnose a valid-certificate request that should still be denied
  • Review function invocation separately from execution authority

Before you start

Platform engineers who run a service mesh or serverless functions, and service owners who define which callers may use which operations

  • Distinguish authenticated workload identity from permission to call a service action.

Refresh first: Workload identity federation: short-lived, scoped identities for services and CI

Go to the guided practice

Two valid identities, one allowed call

Control planebilling podistiod CAreports podsupport podbillingsidecarbillingserviceCSR with theservice accountcredential1Certificate forns/reports/sa/reports1mTLS: POST /invoices/I7/export2ALLOW rulefor thisprincipal3Forward theallowed request3I7 is a Northinvoice:allowed4mTLS: POST/invoices/I7/export5403: no rule forthe supportprincipal5Trust boundaryUntrusted input1Step, described below
  1. Identity issuedEach pod's Istio agent sends a certificate signing request with its Kubernetes service account credential to istiod, which returns a short-lived certificate carrying the SPIFFE ID spiffe://cluster.local/ns/reports/sa/reports.
  2. Mutual TLSThe reports sidecar opens mutual TLS to the billing sidecar. Each side checks the other's certificate against the mesh trust bundle, and STRICT mode refuses plaintext.
  3. Proxy authorizationThe billing sidecar takes the peer principal cluster.local/ns/reports/sa/reports from the certificate and forwards the request only because an ALLOW rule grants that principal POST on /invoices/{*}/export.
  4. Object checkThe billing service loads invoice I7 from its own storage and checks that reports may act for its tenant, North; the same call for I8, a South invoice, is refused.
  5. Valid identity, no grantSupport completes mutual TLS with an equally valid certificate, but no rule grants its principal the export, so the sidecar answers 403 and the billing service never sees the request.
The certificate proves which workload is calling. The sidecar policy decides which operations it may call, and the application still decides whose data it may touch.

A workload identity names a caller, not its entire permission set

Mutual TLS can authenticate both ends of a connection and protect traffic. It does not determine whether the caller may invoke an administrative method or read another tenant's record. A service mesh adds infrastructure-level identity and policy, while application logic still owns business permissions.

Map the identity issuance path: which workload attributes are attested, which identity is issued, how trust bundles are distributed and who can change those mappings. SPIFFE provides a model for workload identifiers and identity documents, which the workload identity models guide at /domains/identity-crypto/identity/workload-identity-models/ compares with cloud-native options; deployments must still protect the attestation and issuance components. A compromised issuer can undermine every policy that trusts its identities.

Worked service call: a valid certificate is only the first decision

The billing service trusts certificates from a particular workload trust domain. Both reports and support hold valid identities from that domain, but only reports may invoke ExportInvoice. If billing accepts any certificate from the domain as authorization for that method, support inherits a capability it was never intended to have. Verify the peer’s issued identity against the service/method rule after validating the transport credentials.

Even reports must not choose arbitrary tenants. In this example its capability is restricted to North, and the application loads the invoice’s tenant from authoritative storage. The required checks are therefore transport peer identity, method permission and object relationship. An mTLS access log proving reports connected successfully does not establish which tenant the returned invoice belonged to.

Now consider a function fed by a queue. For SQS, Lambda itself polls the queue through an event source mapping, using the function's execution role, so the invocation control is who may create or change that mapping and which queue the role may read. For SNS, S3 or EventBridge the service invokes the function through a resource-based permission, which should name the source with aws:SourceArn. Either way, invocation authority is distinct from the execution role's permission to read storage. A broad execution role can expose unrelated data even if invocation is tightly restricted, and a narrow execution role does not stop an unapproved source from consuming concurrency. Review both directions, event validation and duplicate delivery.

The supplied reused-worker example also shows why request context must remain local. A function instance can process different invocations over time. Saving North’s token or tenant in mutable process-global state and reusing it when a later event omits a field can cross an invocation boundary. Reject missing required context and derive each invocation’s authority from its authenticated event contract; reuse immutable clients, not one caller’s mutable authorization state.

Plain text
IDENTITIES (synthetic SPIFFE-style example)
spiffe://example.test/ns/prod/sa/reports
spiffe://example.test/ns/prod/sa/support
Policy: reports may ExportInvoice for tenant North; support may GetHealth only
Invoice I7 tenant=North; I8 tenant=South
Function F: approved queue Q may invoke; execution role may read north/* only

Express the allowed service graph explicitly

Start with a call inventory and grant only required source-to-destination operations. In a mesh, examine both authentication mode and authorization policy. A permissive migration mode may accept plaintext even when some traffic uses mTLS. Check bypass paths through direct listeners, host networking, excluded ports or workloads that do not participate in the mesh.

Protect policy administration and define namespace or trust-domain boundaries. Do not equate possession of any valid certificate with membership in the intended application. Test identity rotation, expired credentials and trust-bundle changes with long-lived connections. Operators need a recovery procedure that restores the control plane without permanently allowing unauthenticated service traffic.

The Istio policies below were tested with Istio 1.31.1 in sidecar mode on a kind cluster. In the default PERMISSIVE mode with no policy, reports, support and a pod without a sidecar could all export invoices. With the policies applied, reports could export but not read /health, support could read /health but not export, every denial was a 403 from the proxy, and the pod without a sidecar could no longer connect. The first version of the policy used /invoices/*/export, which Istio does not treat as a wildcard because * works only at the start or end of a path, so it denied reports too; the {*} template matches one segment, and /invoices/I7/extra/export stayed denied. Policy changes took several seconds to reach the proxies, so test after they propagate.

Configurationbilling-policies.yamlYAML
# Every workload in billing accepts only mutual TLS (no plaintext fallback)
apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata:
  name: default
  namespace: billing
spec:
  mtls:
    mode: STRICT
---
# Allow only these calls to billing; anything not listed is denied with 403
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
  name: billing-callers
  namespace: billing
spec:
  selector:
    matchLabels: {app: billing}
  action: ALLOW
  rules:
    - from:
        - source:
            principals: ["cluster.local/ns/reports/sa/reports"]
      to:
        - operation:
            methods: ["POST"]
            paths: ["/invoices/{*}/export"] # {*} is one path segment; * alone works only at either end
    - from:
        - source:
            principals: ["cluster.local/ns/support/sa/support"]
      to:
        - operation:
            methods: ["GET"]
            paths: ["/health"]

Separate invocation authority from execution authority

A function has at least two permission questions: who may invoke it, and what its execution identity may access. Review event-source permissions, resource policies and downstream roles separately. Validate event shape, source context and the requested business action; a well-formed queue message is not automatically an authorized instruction.

Use narrow execution roles, bounded concurrency and the platform's documented retry semantics. Do not retain one user's sensitive mutable state in a reused execution environment where later invocations could receive it. Make duplicate events idempotent, as the bounded background work guide at /domains/runtime/availability/bounded-background-work/ shows, and prevent recursive triggers from generating unbounded work. Identity federation reduces static credentials, but its trust conditions still need exact scoping; the cloud IAM trust guide at /domains/runtime/cloud-posture/cloud-iam-trust/ covers them.

The Terraform below scopes both directions for the export function: an SQS event source mapping with partial batch responses and a concurrency cap, an SNS permission limited to one topic and account, and an execution role that can read only its queue and the north prefix. Checkov rule CKV_AWS_364 passed it and failed the same permission without source_arn, which would let any SNS topic in any account invoke the function.

Configurationexport-function.tfHCL
# Invocation: an SQS trigger is an event source mapping that Lambda polls with the function's
# execution role. SNS, S3 and EventBridge invoke through a resource-based permission instead.
resource "aws_lambda_event_source_mapping" "exports_queue" {
  event_source_arn        = "arn:aws:sqs:eu-west-1:111122223333:exports"
  function_name           = "export-north"
  batch_size              = 10
  function_response_types = ["ReportBatchItemFailures"]
  scaling_config {
    maximum_concurrency = 5
  }
}

resource "aws_lambda_permission" "alerts_topic" {
  statement_id   = "AllowAlertsTopic"
  action         = "lambda:InvokeFunction"
  function_name  = "export-north"
  principal      = "sns.amazonaws.com"
  source_arn     = "arn:aws:sns:eu-west-1:111122223333:export-alerts" # only this topic
  source_account = "111122223333"
}

# Execution: the role reads its own queue and one tenant prefix, nothing else
data "aws_iam_policy_document" "export_north_execution" {
  statement {
    actions   = ["sqs:ReceiveMessage", "sqs:DeleteMessage", "sqs:GetQueueAttributes"]
    resources = ["arn:aws:sqs:eu-west-1:111122223333:exports"]
  }
  statement {
    actions   = ["s3:GetObject"]
    resources = ["arn:aws:s3:::exports/north/*"]
  }
}

resource "aws_iam_role_policy" "export_north_execution" {
  role   = "export-north-execution"
  policy = data.aws_iam_policy_document.export_north_execution.json
}

Keep each invocation's authority local

The handler below reads the tenant from every message and refuses a message without one, instead of falling back to a value a previous invocation left in a global. It reuses the S3 client, which holds no caller authority, and returns rejected messages as batch item failures so they are retried and then reach the dead-letter queue. In its tests, a message without a tenant processed in the same warm process right after a North message was rejected and nothing was read, and an unknown tenant was rejected while a valid message in the same batch was still processed.

Fixedhandler.pyPython
"""SQS-triggered export function. Each message must carry its own tenant: nothing is read from
an earlier invocation, because Lambda reuses the same process for many callers."""
import json

import boto3

s3 = boto3.client("s3")  # safe to reuse: a client holds no caller's authority
TENANTS = {"north", "south"}


def handler(event, context):
    failures = []
    for record in event["Records"]:
        try:
            message = json.loads(record["body"])
            tenant = message["tenant"]  # required; never fall back to a previous message
            if tenant not in TENANTS:
                raise ValueError(f"unknown tenant {tenant!r}")
            s3.get_object(Bucket="exports", Key=f"{tenant}/{message['report']}")
        except (KeyError, ValueError) as err:
            print(json.dumps({"rejected": record["messageId"], "reason": repr(err)}))
            failures.append({"itemIdentifier": record["messageId"]})  # retried, then dead-lettered
    return {"batchItemFailures": failures}  # needs ReportBatchItemFailures on the mapping

Test identity changes and unauthorized peers

Try the intended caller, another valid workload identity and a caller outside the trust boundary. Observe both transport establishment and method result. Then rotate credentials, expire a test identity and replay a function event. Keep an authorized successful call in the suite. Record mesh/platform versions and policies so a control-plane or deployment change triggers revalidation rather than inheriting assumptions from an earlier topology.

The script below makes each caller try both billing operations and prints the status code, or a connection failure; it produced the results described above.

Testprobe-billing.shShell
# Each caller tries the export and the health check on billing (status code or connection error)
for caller in reports support legacy; do
  for request in "POST /invoices/I7/export" "GET /health"; do
    method=${request% *}; path=${request#* }
    code=$(kubectl -n "$caller" exec client -c curl -- \
      curl -s -o /dev/null -w '%{http_code}' -X "$method" "http://billing.billing.svc.cluster.local$path" 2>/dev/null)
    echo "$caller $request -> ${code:-connection failed}"
  done
done

Diagnose incomplete fixes

A mesh in permissive mode can accept both authenticated mTLS and plaintext traffic. A successful mTLS call does not prove plaintext is rejected. Test direct/excluded listeners and the actual destination policy; a sidecar existing in the pod is not itself an authorization rule.

Credential rotation tests should include already-open connections and newly established ones. A connection may retain a previously authenticated peer context according to the implementation. State the revocation and reauthentication contract rather than assuming certificate expiry instantly terminates every existing stream.

Check the result against concrete cases

These outcomes belong to the worked example; the mesh probe reproduced the method rows and the handler tests the invocation rows, while the object-tenant row needs the application's own test.

Check the result against concrete cases
CaseExpected resultWhat it establishes
reports exports I7AllowRequired service and object relationship
support exports I7 with valid certificateDeny methodTrusted domain membership is not the method grant
reports exports I8Deny object scopeWorkload identity does not erase tenant isolation
Unapproved queue invokes FDeny invocationIngress authority is independent
F reads south/aDeny execution capabilityDownstream access is separately restricted

Guided practice

Apply the supplied policy to the five calls and diagnose the reused function instance. No mesh or cloud account is required.

Supplied scenario material
Plain text
C1 reports -> ExportInvoice(I7), valid mTLS.
C2 support -> ExportInvoice(I7), valid mTLS.
C3 reports -> ExportInvoice(I8), valid mTLS.
C4 plaintext -> GetHealth via an excluded port; deployment claims all inbound traffic requires mTLS.
F invocation A from Q includes tenant=North; stores global lastTenant=North.
F invocation B from Q omits tenant; code falls back to lastTenant and reads north/report.csv.
  1. 1. Evaluate layered authorization

    Decide C1–C3 and name the first failing business boundary.

    Decide each call

    C1: reports → ExportInvoice(I7)
    C2: support → ExportInvoice(I7)
    C3: reports → ExportInvoice(I8)

    Expected result and reasoning

    C1 is allowed. C2 fails method authorization despite a valid certificate. C3 passes service/method identity but fails the invoice tenant relationship.

  2. 2. Test the deployment claim

    Explain what C4 would demonstrate if it succeeds.

    Expected result and reasoning

    It disproves the claim that all inbound traffic requires mTLS. The excluded port is a separate path; a secure result on the ordinary mesh listener does not cover it.

  3. 3. Repair invocation context

    Explain why B must not inherit A’s tenant even though both events came from Q.

    Expected result and reasoning

    Q’s invocation permission does not supply B’s missing required business context. Reject B’s malformed event, or resolve its tenant through an explicit authenticated authoritative mapping. Process reuse must not fill missing authorization fields from a prior caller.

  4. 4. Choose operational evidence

    List observations that verify the complete allowed path.

    Expected result and reasoning

    Record peer identity and method decision, authoritative invoice tenant and object decision, plaintext/bypass rejection, and per-invocation event validation. For F, also test its downstream role cannot read south/*; invocation logs alone do not prove this.

Your deliverable: Decisions for C1 to C3 with the first failing boundary, what C4 would disprove, the repair for invocation B and the evidence that covers the whole allowed path.

The calls and invocations are supplied; the Istio policies, Terraform and handler on this page show the same decisions in tested configuration and code.

Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.

No draft saved on this device yet.

Sources & further reading

The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.

  1. Istio security concepts
  2. SPIFFE concepts: SPIFFE IDs, SVIDs and trust bundles
  3. Istio: SPIRE integration (the Istio SPIFFE ID pattern)
  4. AWS Lambda best practices
  5. Istio: AuthorizationPolicy reference
  6. Istio: PeerAuthentication reference
  7. AWS Lambda: resource-based policies
  8. AWS Lambda: configuring an SQS event source
  9. AWS Lambda: partial batch responses for SQS

Reference information

ASVS 5.0.0 requirements
v5.0.0-12.3.3 · v5.0.0-12.3.5 · v5.0.0-8.2.1

Download this guide as Markdown