AWS S3 Presigned URLs: The Security Mistakes 90% of Developers Make

AWS S3 Presigned URLs: The Security Mistakes 90% of Developers Make

Presigned URLs are one of AWS’s most useful features and one of its most commonly misused. They let you grant temporary access to private S3 objects without exposing your credentials. But “temporary” is relative — a presigned URL that expires in 7 days is functionally public for 7 days. And that’s just the first mistake. Here are the six security issues that actually matter in production.

TL;DR: Keep presigned URL expiry under 15 minutes for sensitive data. Use IAM roles (not access keys) to sign URLs. Never log full URLs — the signature is a credential. Validate the object key server-side before signing. Use bucket policies with conditions to enforce restrictions the presigned URL can’t bypass.

How Presigned URLs Work (What Most Docs Skip)

// Generating a presigned URL — AWS SDK v3 (Node.js)
const { S3Client, GetObjectCommand } = require('@aws-sdk/client-s3');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');

const client = new S3Client({ region: 'us-east-1' });

// ✅ Correct: short expiry, IAM role, validated key
async function generatePresignedUrl(userId, objectKey) {
  // Validate objectKey belongs to userId (critical!)
  if (!objectKey.startsWith(`users/${userId}/`)) {
    throw new Error('Unauthorized: object key does not belong to user');
  }
  
  const command = new GetObjectCommand({
    Bucket: 'my-private-bucket',
    Key: objectKey,
  });
  
  // Expiry: 900 seconds = 15 minutes (not 7 days!)
  const url = await getSignedUrl(client, command, { expiresIn: 900 });
  return url;
}

// The URL looks like:
// https://bucket.s3.amazonaws.com/key?X-Amz-Algorithm=AWS4-HMAC-SHA256
//   &X-Amz-Credential=AKID.../...
//   &X-Amz-Date=20260405T...
//   &X-Amz-Expires=900
//   &X-Amz-Signature=abc123...   ← THIS IS A CREDENTIAL. Never log it.
// Anyone with this URL can download the object until expiry.

Mistake 1: Excessively Long Expiry

AWS security and S3 resources

AWS Security Specialty Course (Udemy) — Full S3 security chapter — presigned URLs, bucket policies, and IAM.

Sponsored links. We may earn a commission at no extra cost to you.

// ❌ Common pattern in tutorials
const url = await getSignedUrl(client, command, { expiresIn: 604800 }); // 7 days!
// If this URL leaks (logs, browser history, Slack message, email):
// - Anyone has access to your private file for 7 days
// - You cannot revoke it without rotating credentials (deleting the IAM key)
// - If signed with an IAM role, you CANNOT revoke it at all

// ✅ Right expiry by use case:
// File download (user clicks link): 300s (5 min)
// Direct upload from browser: 600s (10 min)
// Video streaming (chunked): 3600s (1 hour, refresh with new URL)
// Batch processing: 900s per object, regenerate as needed

// For upload presigned URLs (PutObject) — even stricter:
const uploadCommand = new PutObjectCommand({
  Bucket: 'my-bucket',
  Key: `uploads/${userId}/${filename}`,
  ContentType: 'image/jpeg',        // Lock content type
  ContentLength: expectedFileSize,  // Lock file size
});
const uploadUrl = await getSignedUrl(client, uploadCommand, { expiresIn: 300 });

Mistake 2: Signing With Long-Lived Access Keys

// ❌ Signing with a static IAM access key
const client = new S3Client({
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY
  }
});
// Problem: if the key is compromised, ALL previously generated presigned URLs
// become invalid immediately (breaking users), AND you face a credential rotation crisis

// ✅ Use IAM roles (EC2 instance role, ECS task role, Lambda execution role)
// The SDK automatically uses the role — no credentials in code
const client = new S3Client({ region: 'us-east-1' });
// IAM role credentials rotate automatically every ~6 hours
// Presigned URLs generated with role credentials expire with the role credentials
// Maximum effective lifetime: min(expiresIn, role_credential_expiry)

Mistake 3: Not Validating Object Keys Server-Side

// ❌ Path traversal / IDOR vulnerability
app.get('/download', async (req, res) => {
  const { key } = req.query;
  // User sends: key=users/other-user-id/secret-doc.pdf
  const url = await getSignedUrl(client, new GetObjectCommand({ Bucket, Key: key }));
  res.json({ url }); // User downloads another user's file!
});

// ✅ Always validate the key belongs to the requesting user
app.get('/download', async (req, res) => {
  const { filename } = req.query;  // Take filename only, construct key server-side
  const userId = req.user.id;      // From verified JWT
  
  // Construct key from trusted data — never accept full key from client
  const key = `users/${userId}/${path.basename(filename)}`; // basename prevents ../
  
  // Verify object exists and belongs to user (optional but recommended)
  try {
    await client.send(new HeadObjectCommand({ Bucket, Key: key }));
  } catch (e) {
    return res.status(404).json({ error: 'File not found' });
  }
  
  const url = await getSignedUrl(client, new GetObjectCommand({ Bucket, Key: key }), { expiresIn: 300 });
  res.json({ url });
});

Mistake 4: Logging Full Presigned URLs

// ❌ Logging the full URL exposes the signature credential
app.get('/download', async (req, res) => {
  const url = await getSignedUrl(...);
  console.log('Generated URL:', url); // URL contains X-Amz-Signature!
  // Now: CloudWatch logs, Datadog, Splunk all have access to this "credential"
  res.json({ url });
});

// ✅ Log only safe metadata
app.get('/download', async (req, res) => {
  const url = await getSignedUrl(...);
  const urlObj = new URL(url);
  console.log('Generated presigned URL', {
    bucket: 'my-bucket',
    key: objectKey,
    userId: req.user.id,
    expiresIn: 300,
    // Log query params except the signature
    algorithm: urlObj.searchParams.get('X-Amz-Algorithm'),
    expiry: urlObj.searchParams.get('X-Amz-Expires'),
    // NOT: X-Amz-Signature
  });
  res.json({ url });
});

Bucket Policy to Enforce Additional Restrictions

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyNonPresigned",
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::my-private-bucket/*",
      "Condition": {
        "StringNotEquals": {
          "s3:authType": "REST-QUERY-STRING"  
          // Only allow presigned URL access (not direct authenticated access)
        }
      }
    },
    {
      "Sid": "EnforceSSL",
      "Effect": "Deny",
      "Principal": "*", 
      "Action": "s3:*",
      "Resource": ["arn:aws:s3:::my-private-bucket", "arn:aws:s3:::my-private-bucket/*"],
      "Condition": {
        "Bool": { "aws:SecureTransport": "false" }  // Deny HTTP, require HTTPS
      }
    }
  ]
}

S3 Presigned URL Security Cheat Sheet

  • ✅ Keep expiry under 15 minutes for sensitive data
  • ✅ Always use IAM roles — never static access keys
  • ✅ Validate object keys server-side before signing
  • ✅ Never log full presigned URLs — signature is a credential
  • ✅ Lock ContentType and ContentLength on upload URLs
  • ✅ Use bucket policy to enforce HTTPS and deny public access
  • ❌ Never accept full object keys from client requests
  • ❌ Never set expiry over 1 hour for user-facing downloads

For the complete AWS security picture, these presigned URL patterns connect directly to the Lambda execution role setup — Lambda functions are the most common place presigned URLs are generated, and the role configuration matters for both cold starts and security. For storing presigned URL metadata, the DynamoDB single-table pattern is the right way to track which URLs have been generated per user. Official reference: AWS S3 presigned URL documentation.

Recommended resources

Disclosure: This post contains affiliate links. If you purchase through these links, CheatCoders earns a small commission at no extra cost to you. We only recommend tools and books we genuinely find valuable.


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply