Bug: AWS SAM LOCAL START API
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
Expected result:
Additional environment details (Ex: Windows, Mac, Amazon Linux etc)
- OS:
sam --version:- 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
Thanks for reporting @mblejano07 and template, will try to repro
@mblejano07 I can see you're hitting two distinct issues here. Let me break down what's happening:
The CORS issue: Even though you've defined CORS in your Globals.Api section, you're using a custom DefinitionBody in your InvoiceApi resource which completely overrides those global settings. When you provide an explicit OpenAPI/Swagger definition, SAM doesn't merge the global CORS config into it. Your OPTIONS method mock integration is there, but it's not actually returning the CORS headers. You need to add the response headers explicitly in the integration response. Under your OPTIONS method's x-amazon-apigateway-integration, add an integrationResponses section that returns the actual CORS headers with values like '*' for Allow-Origin, 'GET,POST,PUT,PATCH,DELETE,OPTIONS' for Allow-Methods, and 'Content-Type,Authorization' for Allow-Headers.
The UnicodeDecodeError: This is the more interesting one. You've correctly declared multipart/form-data as a binary media type in your OpenAPI definition, which tells API Gateway to base64-encode the body before sending it to Lambda. However, SAM Local has historically had issues properly handling this encoding/decoding for multipart requests. The error "can't decode byte 0xa1 in position 693" suggests that binary file data is being passed as raw bytes when your Lambda is trying to decode it as UTF-8 text. In your CreateInvoiceFunction handler, you need to check if the isBase64Encoded flag is set to true in the event, and if so, base64-decode the body before parsing it. You'll want to use a library like python-multipart or manually parse the multipart boundary after decoding. The position (693 bytes in) suggests it's hitting binary PDF/image data that was uploaded.