Skip to content

Bug: AWS SAM LOCAL START API #8221

@mblejano07

Description

@mblejano07

Description:

I'm currently facing two issue's when sending multipart request to AWS SAM LOCAL.

confirm-invoice:1 Access to fetch at 'http://localhost:3000/invoices' from origin 'http://localhost:5173' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

and

[20/Aug/2025 08:39:46] "OPTIONS /invoices HTTP/1.1" 200 -
2025-08-20 08:39:46,263 | UnicodeDecodeError while processing HTTP request: 'utf-8' codec can't decode byte 0xa1 in position 693: invalid start
byte

Steps to reproduce:

Observed result:

This is my template.yaml

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
Invoice management serverless API with separate Lambdas per operation
plus WorkMail-based OTP login.

Globals:
Function:
Timeout: 50
Runtime: python3.13
Tracing: Active
LoggingConfig:
LogFormat: JSON
Environment:
Variables:
# === Replace in production ===
WORKMAIL_ORGANIZATION_ID: "local-dev" # Prod: actual AWS WorkMail Org ID
OTP_TABLE_NAME: !Ref OtpTable
EMAIL_SOURCE: "[email protected]" # Prod: verified SES email
JWT_SECRET: "local-secret" # Prod: store in Secrets Manager
INVOICES_TABLE_NAME: !Ref InvoicesTable
EMPLOYEES_TABLE_NAME: !Ref EmployeesTable
ACCOUNTS_TABLE_NAME: !Ref AccountsTable
REFRESH_TOKENS_TABLE_NAME: !Ref RefreshTokensTable # 🆕 Added refresh tokens table name
Api:
Cors:
AllowMethods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'"
AllowHeaders: "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'"
AllowOrigin: "'*'"
Auth:
# 🆕 Define the authorizer at the API level
Authorizers:
ApiAuthorizer:
FunctionPayloadType: TOKEN
FunctionArn: !GetAtt JwtAuthorizerFunction.Arn
Identity:
Headers:
- Authorization

Resources:
InvoiceApi:
Type: AWS::Serverless::Api
Properties:
StageName: Prod
DefinitionBody:
swagger: '2.0'
info:
title: !Ref 'AWS::StackName'
schemes:
- 'https'
x-amazon-apigateway-binary-media-types:
- 'multipart/form-data'
- 'application/octet-stream'
paths:
/invoices:
options:
x-amazon-apigateway-integration:
type: 'mock'
requestTemplates:
application/json: '{"statusCode": 200}'
passthroughBehavior: 'when_no_match'
responses:
'200':
headers:
Access-Control-Allow-Origin:
type: 'string'
Access-Control-Allow-Methods:
type: 'string'
Access-Control-Allow-Headers:
type: 'string'
schema: {}
post:
security:
- ApiAuthorizer: []
x-amazon-apigateway-integration:
type: 'aws_proxy'
httpMethod: 'POST'
uri: !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${CreateInvoiceFunction.Arn}/invocations'
passthroughBehavior: 'when_no_match'
responses: {}
get:
security:
- ApiAuthorizer: []
x-amazon-apigateway-integration:
type: 'aws_proxy'
httpMethod: 'POST'
uri: !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${ListInvoicesFunction.Arn}/invocations'
passthroughBehavior: 'when_no_match'
responses: {}
/invoices/{reference_id}:
get:
security:
- ApiAuthorizer: []
parameters:
- name: 'reference_id'
in: 'path'
required: true
type: 'string'
x-amazon-apigateway-integration:
type: 'aws_proxy'
httpMethod: 'POST'
uri: !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${GetInvoiceFunction.Arn}/invocations'
passthroughBehavior: 'when_no_match'
responses: {}
put:
security:
- ApiAuthorizer: []
parameters:
- name: 'reference_id'
in: 'path'
required: true
type: 'string'
x-amazon-apigateway-integration:
type: 'aws_proxy'
httpMethod: 'POST'
uri: !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${UpdateInvoiceFunction.Arn}/invocations'
passthroughBehavior: 'when_no_match'
responses: {}
delete:
security:
- ApiAuthorizer: []
parameters:
- name: 'reference_id'
in: 'path'
required: true
type: 'string'
x-amazon-apigateway-integration:
type: 'aws_proxy'
httpMethod: 'POST'
uri: !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${DeleteInvoiceFunction.Arn}/invocations'
passthroughBehavior: 'when_no_match'
responses: {}
/invoices/{reference_id}/items:
post:
security:
- ApiAuthorizer: []
parameters:
- name: 'reference_id'
in: 'path'
required: true
type: 'string'
x-amazon-apigateway-integration:
type: 'aws_proxy'
httpMethod: 'POST'
uri: !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${AddItemFunction.Arn}/invocations'
passthroughBehavior: 'when_no_match'
responses: {}
/invoices/{reference_id}/items/{item_id}:
delete:
security:
- ApiAuthorizer: []
parameters:
- name: 'reference_id'
in: 'path'
required: true
type: 'string'
- name: 'item_id'
in: 'path'
required: true
type: 'string'
x-amazon-apigateway-integration:
type: 'aws_proxy'
httpMethod: 'POST'
uri: !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${DeleteItemFunction.Arn}/invocations'
passthroughBehavior: 'when_no_match'
responses: {}
/auth/request_otp:
post:
x-amazon-apigateway-integration:
type: 'aws_proxy'
httpMethod: 'POST'
uri: !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${RequestOtpFunction.Arn}/invocations'
passthroughBehavior: 'when_no_match'
responses: {}
/auth/verify_otp:
post:
x-amazon-apigateway-integration:
type: 'aws_proxy'
httpMethod: 'POST'
uri: !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${VerifyOtpFunction.Arn}/invocations'
passthroughBehavior: 'when_no_match'
responses: {}
/employees:
get:
security:
- ApiAuthorizer: []
x-amazon-apigateway-integration:
type: aws_proxy
httpMethod: post
uri:
!Sub "arn:${AWS::Partition}:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${ListEmployeesFunction.Arn}/invocations"
passthroughBehavior: when_no_match
responses: {}
/accounts:
options:
x-amazon-apigateway-integration:
type: 'mock'
requestTemplates:
application/json: '{"statusCode": 200}'
passthroughBehavior: 'when_no_match'
responses:
'200':
headers:
Access-Control-Allow-Origin:
type: 'string'
Access-Control-Allow-Methods:
type: 'string'
Access-Control-Allow-Headers:
type: 'string'
schema: {}
get:
security:
- ApiAuthorizer: []
x-amazon-apigateway-integration:
type: 'aws_proxy'
httpMethod: 'POST'
uri: !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${GetAccountsFunction.Arn}/invocations'
passthroughBehavior: 'when_no_match'
responses: {}

================= Invoice Lambdas =================

CreateInvoiceFunction:
Type: AWS::Serverless::Function
Properties:
Handler: create_invoice.lambda_handler
CodeUri: lambda/
Policies:
- DynamoDBCrudPolicy:
TableName: !Ref InvoicesTable
- DynamoDBReadPolicy:
TableName: !Ref EmployeesTable # 🆕 Read access to get employee details
- DynamoDBReadPolicy:
TableName: !Ref AccountsTable # 🆕 Read access for accounts validation
- S3ReadWritePolicy:
BucketName: !Ref InvoicesBucket # 🆕 S3 permissions for file uploads
Environment:
Variables:
BUCKET_NAME: !Ref InvoicesBucket # 🆕 Pass bucket name to lambda

InvoicesBucket: # 🆕 Added S3 bucket for invoices
Type: AWS::S3::Bucket
Properties:
AccessControl: Private
CorsConfiguration:
CorsRules:
- AllowedOrigins:
- ""
AllowedHeaders:
- "
"
AllowedMethods:
- GET
- PUT
- POST
- DELETE
- HEAD
MaxAge: 3000

my parser

def parse_multipart(event):
"""Parse multipart/form-data requests (file uploads)."""
form_data = {}
files_data = [] # 🆕 This will now be a list to store multiple files

headers = {k.lower(): v for k, v in (event.get("headers") or {}).items()}
content_type = headers.get("content-type")
if not content_type or not content_type.startswith("multipart/form-data"):
    return form_data, files_data

body_bytes = base64.b64decode(event["body"]) if event.get("isBase64Encoded") else (event.get("body") or "").encode("utf-8")
multipart_data = decoder.MultipartDecoder(body_bytes, content_type)

for part in multipart_data.parts:
    content_disposition = part.headers.get(b"Content-Disposition", b"")
    if b"filename" in content_disposition:
        filename_bytes = content_disposition.split(b"filename=")[1].strip(b'"')
        file_data = {
            "filename": filename_bytes.decode("utf-8", "ignore"),
            "content": part.content,
        }
        files_data.append(file_data) # 🆕 Append each file to the list
    else:
        name_bytes = content_disposition.split(b"name=")[1].strip(b'"')
        try:
            form_data[name_bytes.decode("utf-8")] = part.content.decode("utf-8")
        except UnicodeDecodeError:
            form_data[name_bytes.decode("utf-8")] = part.content.decode("latin-1")

return form_data, files_data # 🆕 Return the list of files

My Payload

Image

Expected result:

Additional environment details (Ex: Windows, Mac, Amazon Linux etc)

  1. OS:
  2. sam --version:
  3. AWS region:
# Paste the output of `sam --info` here

{
"version": "1.142.1",
"system": {
"python": "3.13.5",
"os": "macOS-15.6-arm64-arm-64bit-Mach-O"
},
"additional_dependencies": {
"docker_engine": "28.3.2",
"aws_cdk": "Not available",
"terraform": "Not available"
},
"available_beta_feature_env_vars": [
"SAM_CLI_BETA_FEATURES",
"SAM_CLI_BETA_BUILD_PERFORMANCE",
"SAM_CLI_BETA_TERRAFORM_SUPPORT",
"SAM_CLI_BETA_PACKAGE_PERFORMANCE",
"SAM_CLI_BETA_RUST_CARGO_LAMBDA"
]
}

Add --debug flag to command you are running

Metadata

Metadata

Assignees

No one assigned

    Labels

    stage/needs-triageAutomatically applied to new issues and PRs, indicating they haven't been looked at.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions