Why encodeURIComponent Breaks S3 CopyObject with Silent NoSuchKey Errors
How a standard JavaScript URL encoding function silently breaks multi-segment S3 keys in CopyObjectCommand, producing deceptive NoSuchKey errors on objects that already exist.

In event-driven cloud architectures, a common backend workflow involves two-phase object uploads:
- A client uploads an asset directly to a temporary staging prefix in an S3 bucket (e.g.,
staging/<tenantId>/<timestamp>_<filename>.png). - Upon form submission or validation, a backend worker moves the object from the staging prefix to its permanent production location by issuing an S3
CopyObjectCommandfollowed by aDeleteObjectCommand.
Recently, while investigating an intermittent file-processing failure, we uncovered a deceptive defect: the CopyObjectCommand failed consistently with NoSuchKey, reporting that the source object did not exist—even though direct inspection of the bucket proved the object was present, properly permissioned, and byte-complete.
The root cause was not an eventual consistency delay, a lifecycle race condition, or an IAM permission denial. It was a single URL-encoding call that violated S3’s path delimiter semantics.
1. The Symptom: Deceptive NoSuchKey Errors
The failure surfaced through application telemetry during document ingestion:
Error: Failed to process document upload: Failed to move file to final location: NoSuchKey: The specified key does not exist.
at StorageService.promoteStagedObject (src/services/storage.service.ts:142)
The error message was deeply misleading:
NoSuchKeystrongly implies a timing bug: the file was either never uploaded, uploaded under a different UUID, or prematurely pruned by an automated S3 lifecycle policy.- However, querying the bucket directly with the AWS CLI confirmed the object was present at the exact key passed to the function:
aws s3api head-object \
--bucket production-assets-bucket \
--key "staging/tenant_a48/1726588200_document.pdf"
{
"AcceptRanges": "bytes",
"LastModified": "2026-09-17T14:30:00+00:00",
"ContentLength": 524288,
"ETag": "\"8b1a9953c4611296a827abf8c47804d7\"",
"ContentType": "application/pdf"
}
The object existed. Why was S3 returning NoSuchKey?
2. Tracing the Call Site
Inspecting the promotion helper in src/services/storage.service.ts revealed the following implementation:
import { S3Client, CopyObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
export class StorageService {
constructor(private readonly s3: S3Client, private readonly bucket: string) {}
async promoteStagedObject(tempFileKey: string, finalFileKey: string): Promise<void> {
try {
// Build S3 CopySource parameter
const copyCommand = new CopyObjectCommand({
Bucket: this.bucket,
Key: finalFileKey,
CopySource: `/${this.bucket}/${encodeURIComponent(tempFileKey)}`,
});
await this.s3.send(copyCommand);
// Clean up original staging file
await this.s3.send(
new DeleteObjectCommand({
Bucket: this.bucket,
Key: tempFileKey,
})
);
} catch (error) {
throw new Error(`Failed to move file to final location: ${error}`);
}
}
}
Notice line 12:
CopySource: `/${this.bucket}/${encodeURIComponent(tempFileKey)}`
The developer knew that S3 requires URL-encoding on the CopySource parameter to handle special characters such as spaces, plus signs, or non-ASCII characters in object keys. To satisfy this requirement, they wrapped the entire tempFileKey in JavaScript’s standard encodeURIComponent().
3. The Underlying Protocol Conflict
To understand why this broke, we have to look at the HTTP specification for the Amazon S3 REST API.
When the AWS SDK issues a CopyObject operation, it translates the CopySource argument into the HTTP request header:
x-amz-copy-source: /bucket-name/source-key
According to RFC 3986 and AWS S3 documentation, the x-amz-copy-source header expects the path to be formatted as:
/<bucket-name>/<key-path>
Where:
- The slash (
/) immediately following the bucket name is the bucket-key delimiter. - Any slashes (
/) within the key path itself represent folder or prefix delimiters. - Characters within each path segment must be percent-encoded if they contain reserved or non-ASCII bytes.
However, encodeURIComponent() operates on a complete component, not a URI path. It aggressively encodes all characters except:
A-Z a-z 0-9 - _ . ! ~ * ' ( )
Crucially, encodeURIComponent encodes the forward slash (/) into %2F.
What Was Expected vs. What Was Sent
When tempFileKey was:
staging/tenant_a48/1726588200_document.pdf
Running encodeURIComponent(tempFileKey) produced:
staging%2Ftenant_a48%2F1726588200_document.pdf
The resulting x-amz-copy-source header sent over the wire was:
x-amz-copy-source: /production-assets-bucket/staging%2Ftenant_a48%2F1726588200_document.pdf
How S3 Interpreted the Request
S3 treats / as a literal hierarchical prefix delimiter, but it treats %2F as literal characters belonging to an object’s flat key name.
S3 did not look for an object at prefix staging/ inside folder tenant_a48/. Instead, S3 searched the root of the bucket for a single object whose literal filename contained the characters %2F:
Key: "staging%2Ftenant_a48%2F1726588200_document.pdf"
Because no object with that literal string existed, S3 responded with:
<Error>
<Code>NoSuchKey</Code>
<Message>The specified key does not exist.</Message>
<Key>staging%2Ftenant_a48%2F1726588200_document.pdf</Key>
</Error>
4. The Resolution
The fix requires encoding each path segment individually, while preserving literal forward slashes as structural delimiters:
// INCORRECT: Encodes '/' into '%2F'
const badSource = `/${bucket}/${encodeURIComponent(tempFileKey)}`;
// CORRECT: Encodes special characters per segment, preserving '/'
const safeSource = `/${bucket}/${tempFileKey
.split('/')
.map(encodeURIComponent)
.join('/')}`;
Encapsulating into a Reusable Helper
In production systems, object key formatting should never be hand-rolled inline. Encapsulate the logic into a typed utility:
/**
* Formats a key for use in S3 CopySource headers.
* Preserves structural forward slashes while percent-encoding
* special characters, spaces, and Unicode bytes within segments.
*/
export function formatS3CopySource(bucket: string, key: string): string {
const cleanBucket = bucket.replace(/^\/+|\/+$/g, '');
const cleanKey = key.replace(/^\/+/g, '');
const encodedKey = cleanKey
.split('/')
.map((segment) => encodeURIComponent(segment))
.join('/');
return `/${cleanBucket}/${encodedKey}`;
}
Using this helper, a key such as:
staging/tenant_42/Q3 Architecture Report (Final).pdf
Encodes correctly to:
/production-bucket/staging/tenant_42/Q3%20Architecture%20Report%20(Final).pdf
S3 parses staging/ and tenant_42/ as prefixes, parses %20 as a space within the filename, locates the underlying object, and completes the copy in milliseconds.
5. Why the Silent Swallowing Pattern Made It Worse
The second hazard in this incident was architectural: error handling in asynchronous upload pipelines.
In the original caller service, multiple file uploads were processed in parallel using Promise.allSettled() or individual try/catch blocks:
for (const file of uploadedFiles) {
try {
await storageService.promoteStagedObject(file.tempKey, file.finalKey);
} catch (err) {
// Logged as warning, but request continued!
logger.warn(`Failed to process file for field ${file.fieldName}: ${err}`);
}
}
// Parent API returned HTTP 201 Created!
return res.status(201).json({ status: 'success' });
Because the error was logged rather than rethrown as an HTTP 500:
- The user’s form submission appeared to succeed.
- The database record was created with a pointer to the final key path.
- The staging object remained stuck in S3 temporary storage.
- The failure only became evident days later when a downstream consumer attempted to download the non-existent final object.
6. Key Architectural Takeaways
encodeURIComponentis not path-safe: Never pass full file paths or URLs toencodeURIComponent(). It treats delimiters as data. For multi-segment paths, split on/, encode individual segments, and rejoin.NoSuchKeydoes not always mean missing data: In cloud object stores, a delimiter encoding error produces the identical HTTP 404 /NoSuchKeystatus code as an object that was deleted or never uploaded.- Audit S3
CopySourcecall sites: Grep your codebases forCopySourceorx-amz-copy-source. Any invocation wrapping an entire path variable inencodeURIComponentis a latent bug waiting for a multi-segment key. - Never swallow storage mutation failures: If an object promotion or copy fails, the encapsulating database transaction must abort or raise an explicit error. Silent degradation creates phantom data that is far harder to diagnose in production.