Skip to main content

Sign requests

If you want to get started quickly, click on the button below.

Deploy to Cloudflare

This creates a repository in your GitHub account and deploys the application to Cloudflare Workers.

Note

This example Worker makes use of the Node.js Buffer API, which is available as part of the Workers runtime Node.js compatibility mode. To run this Worker, you will need to enable the nodejs_compat compatibility flag.

You can both verify and generate signed requests from within a Worker using the Web Crypto APIs.

The following Worker will:

  • For request URLs beginning with /generate/, replace /generate/ with /, sign the resulting path with its timestamp, and return the full, signed URL in the response body.

  • For all other request URLs, verify the signed URL and allow the request through.

import { Buffer } from "node:buffer";

const encoder = new TextEncoder();

// How long an HMAC token should be valid for, in seconds
const EXPIRY = 60;

export default {
	/**
	 *
	 * @param {Request} request
	 * @param {{SECRET_DATA: string}} env
	 * @returns
	 */
	async fetch(request, env) {
		// You will need some secret data to use as a symmetric key. This should be
		// attached to your Worker as an encrypted secret.
		// Refer to https://developers.cloudflare.com/workers/configuration/secrets/
		const secretKeyData = encoder.encode(
			env.SECRET_DATA ?? "my secret symmetric key",
		);

		// Import your secret as a CryptoKey for both 'sign' and 'verify' operations
		const key = await crypto.subtle.importKey(
			"raw",
			secretKeyData,
			{ name: "HMAC", hash: "SHA-256" },
			false,
			["sign", "verify"],
		);

		const url = new URL(request.url);

		// This is a demonstration Worker that allows unauthenticated access to /generate
		// In a real application you would want to make sure that
		// users could only generate signed URLs when authenticated
		if (url.pathname.startsWith("/generate/")) {
			url.pathname = url.pathname.replace("/generate/", "/");

			const timestamp = Math.floor(Date.now() / 1000);

			// This contains all the data about the request that you want to be able to verify
			// Here we only sign the timestamp and the pathname, but often you will want to
			// include more data (for instance, the URL hostname or query parameters)
			const dataToAuthenticate = `${url.pathname}${timestamp}`;

			const mac = await crypto.subtle.sign(
				"HMAC",
				key,
				encoder.encode(dataToAuthenticate),
			);

			// Refer to https://developers.cloudflare.com/workers/runtime-apis/nodejs/
			// for more details on using Node.js APIs in Workers
			const base64Mac = Buffer.from(mac).toString("base64");

			url.searchParams.set("verify", `${timestamp}-${base64Mac}`);

			return new Response(`${url.pathname}${url.search}`);
			// Verify all non /generate requests
		} else {
			// Make sure you have the minimum necessary query parameters.
			if (!url.searchParams.has("verify")) {
				return new Response("Missing query parameter", { status: 403 });
			}

			const [timestamp, hmac] = url.searchParams.get("verify").split("-");

			const assertedTimestamp = Number(timestamp);

			const dataToAuthenticate = `${url.pathname}${assertedTimestamp}`;

			const receivedMac = Buffer.from(hmac, "base64");

			// Use crypto.subtle.verify() to guard against timing attacks. Since HMACs use
			// symmetric keys, you could implement this by calling crypto.subtle.sign() and
			// then doing a string comparison -- this is insecure, as string comparisons
			// bail out on the first mismatch, which leaks information to potential
			// attackers.
			const verified = await crypto.subtle.verify(
				"HMAC",
				key,
				receivedMac,
				encoder.encode(dataToAuthenticate),
			);

			if (!verified) {
				return new Response("Invalid MAC", { status: 403 });
			}

			// Signed requests expire after one minute. Note that this value should depend on your specific use case
			if (Date.now() / 1000 > assertedTimestamp + EXPIRY) {
				return new Response(
					`URL expired at ${new Date((assertedTimestamp + EXPIRY) * 1000)}`,
					{ status: 403 },
				);
			}
		}

		return fetch(new URL(url.pathname, "https://example.com"), request);
	},
};
import { Buffer } from "node:buffer";

const encoder = new TextEncoder();

// How long an HMAC token should be valid for, in seconds
const EXPIRY = 60;

interface Env {
	SECRET_DATA: string;
}
export default {
	async fetch(request, env): Promise<Response> {
		// You will need some secret data to use as a symmetric key. This should be
		// attached to your Worker as an encrypted secret.
		// Refer to https://developers.cloudflare.com/workers/configuration/secrets/
		const secretKeyData = encoder.encode(
			env.SECRET_DATA ?? "my secret symmetric key",
		);

		// Import your secret as a CryptoKey for both 'sign' and 'verify' operations
		const key = await crypto.subtle.importKey(
			"raw",
			secretKeyData,
			{ name: "HMAC", hash: "SHA-256" },
			false,
			["sign", "verify"],
		);

		const url = new URL(request.url);

		// This is a demonstration Worker that allows unauthenticated access to /generate
		// In a real application you would want to make sure that
		// users could only generate signed URLs when authenticated
		if (url.pathname.startsWith("/generate/")) {
			url.pathname = url.pathname.replace("/generate/", "/");

			const timestamp = Math.floor(Date.now() / 1000);

			// This contains all the data about the request that you want to be able to verify
			// Here we only sign the timestamp and the pathname, but often you will want to
			// include more data (for instance, the URL hostname or query parameters)
			const dataToAuthenticate = `${url.pathname}${timestamp}`;

			const mac = await crypto.subtle.sign(
				"HMAC",
				key,
				encoder.encode(dataToAuthenticate),
			);

			// Refer to https://developers.cloudflare.com/workers/runtime-apis/nodejs/
			// for more details on using NodeJS APIs in Workers
			const base64Mac = Buffer.from(mac).toString("base64");

			url.searchParams.set("verify", `${timestamp}-${base64Mac}`);

			return new Response(`${url.pathname}${url.search}`);
			// Verify all non /generate requests
		} else {
			// Make sure you have the minimum necessary query parameters.
			if (!url.searchParams.has("verify")) {
				return new Response("Missing query parameter", { status: 403 });
			}

			const [timestamp, hmac] = url.searchParams.get("verify").split("-");

			const assertedTimestamp = Number(timestamp);

			const dataToAuthenticate = `${url.pathname}${assertedTimestamp}`;

			const receivedMac = Buffer.from(hmac, "base64");

			// Use crypto.subtle.verify() to guard against timing attacks. Since HMACs use
			// symmetric keys, you could implement this by calling crypto.subtle.sign() and
			// then doing a string comparison -- this is insecure, as string comparisons
			// bail out on the first mismatch, which leaks information to potential
			// attackers.
			const verified = await crypto.subtle.verify(
				"HMAC",
				key,
				receivedMac,
				encoder.encode(dataToAuthenticate),
			);

			if (!verified) {
				return new Response("Invalid MAC", { status: 403 });
			}

			// Signed requests expire after one minute. Note that this value should depend on your specific use case
			if (Date.now() / 1000 > assertedTimestamp + EXPIRY) {
				return new Response(
					`URL expired at ${new Date((assertedTimestamp + EXPIRY) * 1000)}`,
					{ status: 403 },
				);
			}
		}

		return fetch(new URL(url.pathname, "https://example.com"), request);
	},
} satisfies ExportedHandler<Env>;
import { Buffer } from "node:buffer";
import { Hono } from "hono";
import { proxy } from "hono/proxy";

const encoder = new TextEncoder();

// How long an HMAC token should be valid for, in seconds
const EXPIRY = 60;

interface Env {
	SECRET_DATA: string;
}

const app = new Hono<{ Bindings: Env }>();

// Handle URL generation requests
app.get("/generate/*", async (c) => {
	const env = c.env;

	// You will need some secret data to use as a symmetric key
	const secretKeyData = encoder.encode(
		env.SECRET_DATA ?? "my secret symmetric key",
	);

	// Import the secret as a CryptoKey for both 'sign' and 'verify' operations
	const key = await crypto.subtle.importKey(
		"raw",
		secretKeyData,
		{ name: "HMAC", hash: "SHA-256" },
		false,
		["sign", "verify"],
	);

	// Replace "/generate/" prefix with "/"
	const url = new URL(c.req.url);
	url.pathname = url.pathname.replace("/generate/", "/");

	const timestamp = Math.floor(Date.now() / 1000);

	// Data to authenticate: pathname + timestamp
	const dataToAuthenticate = `${url.pathname}${timestamp}`;

	// Sign the data
	const mac = await crypto.subtle.sign(
		"HMAC",
		key,
		encoder.encode(dataToAuthenticate),
	);

	// Convert the signature to base64
	const base64Mac = Buffer.from(mac).toString("base64");

	// Add verification parameter to URL
	url.searchParams.set("verify", `${timestamp}-${base64Mac}`);

	return c.text(url.toString());
});

// Handle verification for all other requests
app.all("*", async (c) => {
	const env = c.env;
	const url = c.req.url;

	// You will need some secret data to use as a symmetric key
	const secretKeyData = encoder.encode(
		env.SECRET_DATA ?? "my secret symmetric key",
	);

	// Import the secret as a CryptoKey for both 'sign' and 'verify' operations
	const key = await crypto.subtle.importKey(
		"raw",
		secretKeyData,
		{ name: "HMAC", hash: "SHA-256" },
		false,
		["sign", "verify"],
	);

	// Make sure the request has the verification parameter
	if (!c.req.query("verify")) {
		return c.text("Missing query parameter", 403);
	}

	// Extract timestamp and signature
	const [timestamp, hmac] = c.req.query("verify")!.split("-");
	const assertedTimestamp = Number(timestamp);

	// Recreate the data that should have been signed
	const dataToAuthenticate = `${c.req.path}${assertedTimestamp}`;

	// Convert base64 signature back to ArrayBuffer
	const receivedMac = Buffer.from(hmac, "base64");

	// Verify the signature
	const verified = await crypto.subtle.verify(
		"HMAC",
		key,
		receivedMac,
		encoder.encode(dataToAuthenticate),
	);

	// If verification fails, return 403
	if (!verified) {
		return c.text("Invalid MAC", 403);
	}

	// Check if the signature has expired
	if (Date.now() / 1000 > assertedTimestamp + EXPIRY) {
		return c.text(
			`URL expired at ${new Date((assertedTimestamp + EXPIRY) * 1000)}`,
			403,
		);
	}

	// If verification passes, proxy the request to example.com
	return proxy(`https://example.com${c.req.path}`, { ...c.req });
});

export default app;
from workers import WorkerEntrypoint
from pyodide.ffi import to_js as _to_js
from js import Response, URL, TextEncoder, Buffer, fetch, Object, crypto

def to_js(x):
    return _to_js(x, dict_converter=Object.fromEntries)

encoder = TextEncoder.new()

# How long an HMAC token should be valid for, in seconds
EXPIRY = 60

class Default(WorkerEntrypoint):
    async def fetch(self, request):
        # Get the secret key
        secret_key_data = encoder.encode(getattr(self.env, "SECRET_DATA", None) or "my secret symmetric key")

        # Import the secret as a CryptoKey for both 'sign' and 'verify' operations
        key = await crypto.subtle.importKey(
            "raw",
            secret_key_data,
            to_js({"name": "HMAC", "hash": "SHA-256"}),
            False,
            ["sign", "verify"]
        )

        url = URL.new(request.url)

        if url.pathname.startswith("/generate/"):
            url.pathname = url.pathname.replace("/generate/", "/", 1)

            timestamp = int(Date.now() / 1000)

            # Data to authenticate
            data_to_authenticate = f"{url.pathname}{timestamp}"

            # Sign the data
            mac = await crypto.subtle.sign(
                "HMAC",
                key,
                encoder.encode(data_to_authenticate)
            )

            # Convert to base64
            base64_mac = Buffer.from(mac).toString("base64")

            # Set the verification parameter
            url.searchParams.set("verify", f"{timestamp}-{base64_mac}")

            return Response.new(f"{url.pathname}{url.search}")
        else:
            # Verify the request
            if not "verify" in url.searchParams:
                return Response.new("Missing query parameter", status=403)

            verify_param = url.searchParams.get("verify")
            timestamp, hmac = verify_param.split("-")

            asserted_timestamp = int(timestamp)

            data_to_authenticate = f"{url.pathname}{asserted_timestamp}"

            received_mac = Buffer.from(hmac, "base64")

            # Verify the signature
            verified = await crypto.subtle.verify(
                "HMAC",
                key,
                received_mac,
                encoder.encode(data_to_authenticate)
            )

            if not verified:
                return Response.new("Invalid MAC", status=403)

            # Check expiration
            if Date.now() / 1000 > asserted_timestamp + EXPIRY:
                expiry_date = Date.new((asserted_timestamp + EXPIRY) * 1000)
                return Response.new(f"URL expired at {expiry_date}", status=403)

        # Proxy to example.com if verification passes
        return fetch(URL.new(f"https://example.com{url.pathname}"), request)

Validate signed requests using the WAF

The provided example code for signing requests is compatible with the is_timed_hmac_valid_v0() Rules language function. This means that you can verify requests signed by the Worker script using a custom rule.