Blog

How I Implemented Secure S3 Presigned URLs and Server Uploads for Procurement Documents

S3-compatible uploads with sanitized keys, server-side PutObject, and presigned GET for invoices and supplier documents in a Next.js App Router CPMS.

How I Implemented Secure S3 Presigned URLs and Server Uploads for Procurement Documents
PMS2 min read2026-04-03
By Published Updated

How I Implemented Secure S3 Presigned URLs and Server Uploads for Procurement Documents

Direct answer (40 words): Use the AWS SDK v3 S3Client with a path-style endpoint for S3-compatible hosts, generate keys with timestamp + random + sanitized filenames, upload server-side from server actions, and issue short-lived presigned GET URLs only when the user is authorized—never expose raw bucket URLs in the UI.

Why this pattern matters

Procurement systems store contracts, invoices, and supplier compliance PDFs. Public URLs or permanent bucket links are a compliance risk. Presigned URLs trade link permanence for time-boxed access aligned with audit expectations.

Key implementation from this codebase

1. Client + key generation

Keys are never taken from the client as-is; filenames are sanitized and prefixed by domain folder.

export function generateS3Key(folder: string, filename: string): string {
  const sanitized = filename.replace(/[^a-zA-Z0-9._-]/g, "_");
  const timestamp = Date.now();
  const randomId = Math.random().toString(36).substring(2, 10);
  return `${folder}/${timestamp}-${randomId}-${sanitized}`;
}

2. Upload and presign

export async function uploadFile(
  buffer: Buffer,
  key: string,
  contentType: string
): Promise<string> {
  const command = new PutObjectCommand({
    Bucket: BUCKET,
    Key: key,
    Body: buffer,
    ContentType: contentType,
  });

  await s3Client.send(command);
  return key;
}

/**
 * Generate a presigned URL for secure, temporary access to an S3 object.
 * @param key - The S3 object key.
 * @param expiresIn - URL validity in seconds (default: 3600 = 1 hour).
 */
export async function getPresignedUrl(
  key: string,
  expiresIn: number = 3600
): Promise<string> {
  const command = new GetObjectCommand({
    Bucket: BUCKET,
    Key: key,
  });

  return getSignedUrl(s3Client, command, { expiresIn });
}

3. Environment contract

.env.example documents S3-compatible variables (Tigris, AWS S3, Cloudflare R2, etc.):

# S3-compatible Storage (e.g. Tigris, AWS S3, Cloudflare R2)
AWS_ACCESS_KEY_ID=""
AWS_SECRET_ACCESS_KEY=""
AWS_REGION="auto"
AWS_BUCKET_NAME=""
S3_ENDPOINT=""

Operational notes

  • Rotate keys and restrict IAM (or equivalent) to PutObject/GetObject on the procurement prefix.
  • Default expiry of one hour balances UX and leakage; shorten for highly sensitive bundles.
  • Keep authorization checks in the server action or route before calling getPresignedUrl.

Read the full case study: CPMS — Comprehensive Procurement Management System

Related reading

Why I Used Prisma Client Extensions for CRUD Notifications (MSIS Perspective)

Cross-cutting notification emission on create/update/delete without breaking mutations, plus permission-aware recipients in CPMS.

Continue reading

Why I Used JSON-Path RBAC and JWT Session Refresh in Auth.js v5 (Next.js 15)

Nested permission JSON, dot-path checks, legacy role fallbacks, and inactivity-aware JWT callbacks for a procurement CPMS.

Continue reading

How I Implemented Department-Scoped Approval Workflows for Requisitions

Amount thresholds, manager vs finance gates, and department matching in a pure TypeScript workflow engine for CPMS.

Continue reading
How I Implemented Secure S3 Presigned URLs and Server Uploads for Procurement Documents | MrHaseeb | MrHaseeb