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.

Two paths through storage shelves: one reaches the object, the copy request follows a path whose separators were replaced and ends at an empty red slot
Editorial Note: Generalized systems engineering RCA; uses synthetic prefixes and standard AWS SDK client libraries.

In event-driven cloud architectures, a common backend workflow involves two-phase object uploads:

  1. A client uploads an asset directly to a temporary staging prefix in an S3 bucket (e.g., staging/<tenantId>/<timestamp>_<filename>.png).
  2. Upon form submission or validation, a backend worker moves the object from the staging prefix to its permanent production location by issuing an S3 CopyObjectCommand followed by a DeleteObjectCommand.

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:

  • NoSuchKey strongly 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:

  1. The user’s form submission appeared to succeed.
  2. The database record was created with a pointer to the final key path.
  3. The staging object remained stuck in S3 temporary storage.
  4. The failure only became evident days later when a downstream consumer attempted to download the non-existent final object.

6. Key Architectural Takeaways

  1. encodeURIComponent is not path-safe: Never pass full file paths or URLs to encodeURIComponent(). It treats delimiters as data. For multi-segment paths, split on /, encode individual segments, and rejoin.
  2. NoSuchKey does not always mean missing data: In cloud object stores, a delimiter encoding error produces the identical HTTP 404 / NoSuchKey status code as an object that was deleted or never uploaded.
  3. Audit S3 CopySource call sites: Grep your codebases for CopySource or x-amz-copy-source. Any invocation wrapping an entire path variable in encodeURIComponent is a latent bug waiting for a multi-segment key.
  4. 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.