What happened
One passport method skipped its API Gateway authorizer, the Lambda handler treated any non-empty partner key as enough, and changing a passport reference returned a different customer's record.
API Gateway is the front door that receives the web request. A method is one verb and path, such as GET /v1/passports/{passportId}. A REQUEST authorizer is a Lambda function API Gateway can call before the method to decide who the caller is. Lambda runs the application code. DynamoDB stores the passport records the code retrieves.
TravelVerify, its staff, partners and passport records are fictional. Every request and response below came from a functional private AWS lab. Two fabricated records were enough to prove the issue. We did not enumerate the dataset, and retained logs cannot prove that an unrecorded event never happened.
How the platform works, and where it was weak
A partner enters a passport reference in the TravelVerify operations portal. The browser sends that reference and an X-Partner-Key header to API Gateway. The intended design is simple: API Gateway calls the authorizer, the authorizer maps the key to one partner, the application checks that partner owns the requested record, and only then does DynamoDB return it [1] [6] [7].
API Gateway skipped identity
The GET method was created with Authorization set to NONE, so API Gateway forwarded the request without a verified partner identity.
The handler confused presence with validity
The application saw a non-empty header and continued even when the registry did not recognise its digest. A string existed, but an identity did not.
The record ID became the decision
The handler retrieved the supplied passport reference without comparing the record owner with an authenticated partner. OWASP calls this Broken Object Level Authorization [6].
How we rebuilt it, stage by stage
The assessment began from the ordinary partner workflow. Each later test changed one thing that the previous response had made visible. The displayed header values are recording aliases, not reusable credentials.
Removing the key returned 401
What made this possibleThe normal portal request exposed the passport path, the record reference and the fact that a partner credential was sent in a header.
The baseline request returned Northstar's own record. Removing the partner header tested whether the application expected authentication at all.
GET /v1/passports/763184295 HTTP/2
Host: partners.travelverify.testThis looked encouraging, but it proved only that the header had to exist. It did not prove the value was validated.
ResultHTTP/2 401 Unauthorized
Response
{"error":"partner authentication required"}The same passport path that returned 200 in normal use returned 401 when the header was absent.
An obviously fake key returned the passport
What made this possibleThe 401 response showed that the code distinguished a missing header from a present one, which justified testing whether it also distinguished an invalid value.
The next request supplied a value that could not be mistaken for a registered partner key. The route returned the same record and status as the valid request.
GET /v1/passports/763184295 HTTP/2
Host: partners.travelverify.test
X-Partner-Key: definitely-not-a-real-keyMissing and invalid credentials produced different outcomes. The application was checking whether a value existed, not whether it belonged to a partner.
ResultHTTP/2 200 OK
Decisive response fields
{
"passport_reference": "763184295",
"partner_name": "Northstar Travel",
"verification_status": "Verified"
}A visibly invalid key returned the same fabricated passport fields as the valid Northstar request.
Changing only the reference crossed the customer boundary
What made this possibleThe normal BlueJet workflow visibly supplied a second fabricated reference and confirmed that its owner was a different partner.
The separate BlueJet portal had already revealed one BlueJet passport reference. Replacing only the Northstar reference with that observed value returned BlueJet's record.
GET /v1/passports/935627140 HTTP/2
Host: partners.travelverify.test
X-Partner-Key: northstar-recording-keyA valid partner key should identify the caller, then the application should compare that identity with the record owner. The vulnerable handler did neither reliably.
ResultHTTP/2 200 OK
Decisive response fields
{
"passport_reference": "935627140",
"partner_name": "BlueJet Holidays",
"verification_status": "Verified"
}The response named BlueJet Holidays while the request still carried the Northstar recording credential.
What defenders would see
The difficult part is that both dangerous requests look like ordinary HTTPS GET requests at the network layer. GuardDuty analyses CloudTrail management events, VPC flow data and DNS data, but it does not know whether a custom X-Partner-Key value is valid or whether a returned passport belongs to the caller [4]. Security Hub can surface API Gateway posture controls, but its managed route-authorisation control is scoped to API Gateway V2 routes, while this lab uses a REST API method [5]. Neither service automatically reports this data exposure.
| Source | What it can answer | What it cannot answer by itself |
|---|---|---|
| API Gateway access logs | Which route returned 200, when, from which source and with which authorizer principal | Whether the returned record belonged to the caller |
| Application audit log | Credential state, authenticated partner, requested owner and allow or deny decision | Whether API Gateway was deployed with the correct authorizer |
| CloudTrail | Who changed API Gateway, Lambda or logging configuration | Which passport fields a normal data-plane request returned |
| GuardDuty | Threat patterns in its supported AWS, network and DNS data sources | A custom business authorisation failure in the response path |
| Security Hub CSPM | Narrow managed posture questions for supported API Gateway resources | A complete REST method and backend ownership assessment |
fields @timestamp, request_id, route, status, source_ip, authorizer_principal_id
| filter route = "/v1/passports/{passportId}"
| filter status = "200"
| filter authorizer_principal_id = "-"
| sort @timestamp asc
| limit 200This application had been live for one month and retained three months of logs, so its whole lifetime was available. If the issue were found a year later with the same retention, the team could examine only the latest three months. Even complete retained logs support the statement "we found no matching evidence in this window", not "this never happened" [2] [3].
How the secure design breaks the chain
The route invokes Lambda without a verified identity.
authorization = "NONE"
authorizer_id = nullAPI Gateway invokes the REQUEST authorizer first.
authorization = "CUSTOM"
authorizer_id = aws_api_gateway_authorizer.partner[0].idThe same four requests now make four correct decisions
What made this possibleThe vulnerable responses established the exact request set that the correction had to change without breaking ordinary partner use.
The secure environment received the same missing, fake, cross-owner and valid-owner requests used against the vulnerable environment.
No partner key -> 401
Fake partner key -> 403
Northstar key, BlueJet record -> 403
Northstar key, Northstar record -> 200The fake key stops at the authorizer. A valid identity that asks for another partner's record stops at the backend ownership check. Normal use still succeeds.
Result401, 403, 403, 200
Decisive application reasons
missing_credential
unauthenticated
partner_does_not_own_record
record_returnedThe paired replay changed both unsafe 200 responses to 403 while preserving the authorised Northstar 200 response.
Check this on your own API
List every sensitive method and its authorisation type
What made this possibleThe live root cause was one method with a different setting from the rest of the API.
Start in API Gateway, open the REST API, choose Resources and select each method that can return or change sensitive data.
aws apigateway get-method \
--rest-api-id <api-id> \
--resource-id <resource-id> \
--http-method GET \
--query "{authorizationType:authorizationType,authorizerId:authorizerId}"ResultSafe answer: CUSTOM or AWS_IAM, with the intended authorizer attached
Any sensitive REST method that unexpectedly reports NONE recreates the first failure in this chain.
Trace where a credential becomes an identity
What made this possibleThe fake-key 200 proved that presence and validity were separate decisions in the vulnerable handler.
Find the code that reads the partner header and follow the invalid-value branch. A non-empty string must never create or imply a caller identity.
registered = registry.lookup_digest(credential_digest(raw_key))
if registered is None:
deny(403)
partner_id = registered.partner_idResultSafe answer: an unknown credential is denied before the data handler runs
Prove ownership with negative tests
What made this possibleThe cross-partner 200 showed that successful authentication alone would not have closed the record-level failure.
Use two test tenants and two fabricated records. A valid credential for one tenant must not retrieve the other tenant's record.
Tenant A credential -> Tenant A record = 200
Tenant A credential -> Tenant B record = 403
Fake credential -> either record = 403
No credential -> either record = 401ResultSafe answer: every object access checks the authenticated tenant against the owner
OWASP recommends object-level authorisation in every function that uses a client-supplied object ID [6].
Reproduce the lab safely
The Practice Lab deploys isolated Range and Secure versions of the fictional TravelVerify API through GitHub Actions. It includes the functional portal, fabricated passport records, API Gateway, the authorizer and application Lambdas, DynamoDB, CloudWatch logging, the Trivy delivery gate, the exact Logs Insights queries and the paired replay. Both environments stay private and use bounded test credentials. The learner destroys them through the same workflow when recording is complete.
| Stage | What the lab proves | Lab action IDs |
|---|---|---|
| Normal use | Northstar and BlueJet each retrieve their own record | N01 to N04 |
| Authentication test | Missing key returns 401 and two fake values return 200 in Range | B05 to B09 |
| Ownership test | Northstar retrieves the observed BlueJet record in Range | O02 to O04 |
| Investigation | Logs Insights, retention, API Gateway and Lambda explain the two unsafe 200 responses | L01 to C07 |
| Delivery control | Trivy blocks the missing authorizer before deployment | G01 to G10 |
| Secure replay | The same requests return 401, 403, 403 and 200 | F01 to F10 |
The pinned public learner release will be linked here after its separate publication review.
Practice Lab release pending (coming after release approval)References
Numbered sources support the practical points in this article. Video timestamps will appear when the related media is published.
- Use API Gateway Lambda authorizershttps://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-use-lambda-authorizer.html
Explains how API Gateway calls a Lambda authorizer before invoking a REST API method and how REQUEST authorizers use headers and other identity sources.
- Set up CloudWatch logging for REST APIs in API Gatewayhttps://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-logging.html
Defines API Gateway access logging, execution logging and the account-level permission needed to deliver logs to CloudWatch.
- CloudWatch Logs Insights query languagehttps://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/CWL_AnalyzeLogData_LogsInsights.html
The query-language reference behind the investigation shown in this article.
- GuardDuty foundational data sourceshttps://docs.aws.amazon.com/guardduty/latest/ug/guardduty_data-sources.html
Shows why GuardDuty does not automatically understand a custom application authorisation decision or the passport fields returned in an API response.
- Security Hub CSPM controls for Amazon API Gatewayhttps://docs.aws.amazon.com/securityhub/latest/userguide/apigateway-controls.html
Lists the managed API Gateway posture checks and their exact resource coverage.
- API1:2023 Broken Object Level Authorizationhttps://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/
Defines the record-ownership failure reproduced by changing the passport reference.
- API2:2023 Broken Authenticationhttps://api-security.owasp.org/editions/2023/en/0xa2-broken-authentication/
Defines authentication failures including credentials whose authenticity is not validated.