Build Your Own Pre-Signed URLs

We’ve all used pre-signed URLs. AWS S3 made them ubiquitous. Recently, I tried implementing a pre-signed URL system for a custom storage service.

If you’re building a file storage service, implementing temporary access controls, or just curious about how these “magic links” actually work under the hood, this post walks through the complete implementation.

Why Pre-Signed URLs Matter

Pre-signed URLs solve a specific problem: you want to grant temporary, controlled access to a resource without requiring authentication headers or managing session state.

The beauty is in the statelessness. Your server doesn’t need a database lookup to validate the request. Everything it needs is embedded in the URL itself. This makes pre-signed URLs incredibly fast and horizontally scalable.

The Four Building Blocks

Before we get into implementation, let’s break down the components of a pre-signed URL.

1. The Master Secret

This is a high-entropy 256 bit random key known only to two parties: the Generator (your backend that creates URLs) and the Verifier (the service that serves files).

Think of this as the cryptographic foundation of the entire system. If this leaks, every signed URL you’ve ever issued is compromised.

2. The Payload

The payload contains the constraints of the request. These must be part of the URL itself to prevent tampering.

Typical fields:

  • file_id: The resource being accessed
  • exp: Expiration timestamp (Unix epoch)
  • version: Key version ID for seamless rotation (optional but recommended)
3. The Signing Algorithm

HMAC-SHA256 is recommended. It’s faster than RSA or other asymmetric algorithms and perfectly suited for internal service-to-service validation where both parties can safely share a secret.

4. The Verifier

This is middleware in your storage service that extracts the payload and signature, checks expiration, re-calculates the signature using the Master Secret, and compares the results using constant time comparison.

Generating the Master Secret

# Generates 32 random bytes as Base64
openssl rand -base64 32

# Generates 32 random bytes as Hex
openssl rand -hex 32

You might be tempted to just type random characters. Don’t !!!

Human “randomness” is predictable. OpenSSL uses a CSPRNG (Cryptographically Secure Pseudo-Random Number Generator) that pulls entropy from unpredictable physical events like keyboard timing, disk I/O, or thermal noise. This ensures that even with massive computing power, an attacker cannot guess your key.

Constructing a Signed URL

The core idea is simple: create a “temporary pass” that proves the user has permission to access a specific file for a limited time.

Building the Signature

Let’s walk through the process step by step.

Step 1: Create the Message

String together the data you want to protect:

Path: /files/video1.mp4
Expiry: 1735689600
Raw String: /files/video1.mp4?expires=1735689600

Step 2: Generate the HMAC

Use your 256-bit Master Secret and SHA-256 to sign that string.

Step 3: Append the Signature

The final URL looks like this:

https://api.yourshop.com/files/video1.mp4?expires=1735689600&signature=a3f9b2...
Implementation

Here’s what this looks like in code:

const crypto = require('crypto');

const secret = 'your-256-bit-master-secret'; // Keep this safe!
const filePath = '/files/1';
const expires = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now

// 1. Create the data string
const dataToSign = `${filePath}?expires=${expires}`;

// 2. Sign it using HMAC-SHA256
const signature = crypto
  .createHmac('sha256', secret)
  .update(dataToSign)
  .digest('hex');

// 3. Construct the final URL
const signedUrl = `https://yourdomain.com${dataToSign}&signature=${signature}`;
console.log(signedUrl);

Server-Side Verification

When a user clicks that link, your server needs to verify that it’s legitimate.

The Four Step Verification Process

Step 1: Parse the URL

Extract the three key ingredients:

  • The Resource: /files/video1.mp4
  • The Metadata: expires=1735689600
  • The Signature: a3f9b2...

Step 2: The Freshness Check

Before doing any cryptographic work, check the clock. If current_time > expires, immediately return 403 Forbidden.

This prevents replay attacks where an attacker reuses an old link weeks later.

Step 3: Recalculate the Expected Signature

This is the core of the security. Take the Resource and Metadata from the URL and run them through HMAC again using your Master Secret.

You’re essentially asking: “If I were to generate a signature for this specific file and expiration date right now, what would it look like?”

Step 4: The Comparison

Compare the signature provided in the URL against the expected signature you just calculated.

If they match, the URL is authentic. Nobody tampered with it because only you know the Master Secret. If they don’t match, someone changed the file ID, extended the expiration, or is just guessing. Access denied.

Implementation

Here’s how verification looks in code. Pay attention to the timing safe comparison:

const crypto = require('crypto');

function verifySignedUrl(req) {
    const { path, query } = req;
    const { expires, signature } = query;
    const secret = process.env.MASTER_SECRET; // Your 256 bit key

    // 1. Expiry Check
    const currentTime = Math.floor(Date.now() / 1000);
    if (currentTime > parseInt(expires)) {
        return { valid: false, reason: 'URL Expired' };
    }

    // 2. Re-create the "data string" exactly as during generation
    const dataToVerify = `${path}?expires=${expires}`;

    // 3. Recalculate the expected signature
    const expectedSignature = crypto
        .createHmac('sha256', secret)
        .update(dataToVerify)
        .digest('hex');

    // 4. Timing safe comparison
    const isValid = crypto.timingSafeEqual(
        Buffer.from(signature, 'hex'),
        Buffer.from(expectedSignature, 'hex')
    );

    return isValid ? { valid: true } : { valid: false, reason: 'Invalid Signature' };
}
Why This Approach Is Powerful

Statelessness: Your server doesn’t need to store issued links in a database. It can verify millions of links per second using only the Master Secret and the URL itself.

Tamper Proof: If a user changes file/1 to file/2 in the URL, the recalculated hash won’t match the provided signature.

Scalability: Since there’s no database lookup, you can deploy this logic in Edge Functions (Cloudflare Workers, AWS Lambda@Edge) for lightning fast verification close to users.

Why HMAC Instead of Plain Hashing?

The Problem with Plain Hashing

A standard hash like SHA-256 is a one way function. If you hash the word “Apple,” you always get the same result.

If an attacker knows you’re using SHA-256, they can hash their own messages to create “valid” signatures.

How HMAC Fixes This

HMAC (Hash based Message Authentication Code) is a hash mixed with a secret key.

When you call createHmac('sha256', secret), the algorithm performs a double hashing process:

  1. Inner Hash: Mix your secret with the message and hash it
  2. Outer Hash: Take that result, mix it with the secret again (using a different internal pattern), and hash it one more time

This double hashing protects against length extension attacks.

Timing Safe Comparison

Here’s where things get really interesting. Even if you implement HMAC correctly, you can still leak your secret through timing attacks.

The Vulnerability: Short Circuiting

Most comparison operators (==, ===) use short circuiting to fail fast:

Checking signature: abcdefg

Guess 1: zbcdefg. Computer checks 'z' ≠ 'a', stops immediately
Guess 2: abcdefz. Computer checks all 7 letters before failing at the end

In standard programming, this is efficient. In cryptography, this is a vulnerability.

The Timing Attack

An attacker can measure time with nanosecond precision. Here’s how they exploit short circuiting:

  1. Send signatures starting with 0, then 1, then 2
  2. Notice that when the signature starts with 4, the server takes 30 nanoseconds longer
  3. That extra time means the first character matched and the computer moved to the second before failing
  4. Now they know the first character is 4
  5. Repeat for each character

By measuring these tiny “time leaks,” an attacker turns a task that should take billions of years (brute forcing a 256 bit hash) into something achievable in hours.

How timingSafeEqual Fixes It

timingSafeEqual forces the CPU to compare every single byte, even if the first byte is wrong:

  • Wrong guess at character 1: Takes 100ms
  • Wrong guess at character 50: Takes 100ms
  • Perfect match: Takes 100ms

Because the time is always identical, the attacker gains zero information from their measurements.

When to Use It

You don’t need timing safe comparison for checking if a username is “Admin.” You only need it for secrets where the value itself is proof of identity:

  • HMAC signatures (like our signed URLs)
  • API keys
  • Password reset tokens
  • Any cryptographic comparison

Key Takeaways

  1. HMAC-SHA256 is the foundation: Combines hashing with a secret key for tamper proof signatures. Its double hashing construction prevents length extension attacks.
  2. Entropy and timing matter: Generate secrets with CSPRNG (OpenSSL). Always use crypto.timingSafeEqual for comparisons because nanosecond timing leaks can break security.
  3. Statelessness = scalability: No database lookups means infinite horizontal scaling and sub millisecond verification.
  4. Symmetric beats asymmetric here: When both signer and verifier are under your control, HMAC is faster and simpler than RSA.