If you want to get started quickly, click on the button below.
This creates a repository in your GitHub account and deploys the application to Cloudflare Workers.
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();
// 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 "/"
let pathname = c.req.path.replace("/generate/", "/");
const timestamp = Math.floor(Date.now() / 1000);
// Data to authenticate: pathname + timestamp
const dataToAuthenticate = `${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(`${pathname}${url.search}`);
});
// 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)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.