Add a visible user label to HTML pages from an application you control. The Worker retrieves Gateway identity and streams the response through HTMLRewriter.
In your Worker project, install @cloudflare/zt-types ↗︎ version 2026.10.1 or later as a development dependency:
npm i -D @cloudflare/zt-typesyarn add -D @cloudflare/zt-typespnpm add -D @cloudflare/zt-typesbun add -d @cloudflare/zt-typesKeep the import type declarations for GatewayMiddlewareContext and GatewayMiddlewareNext in src/index.ts. They describe the Gateway context and continuation and are removed when TypeScript compiles. Import the runtime WorkerEntrypoint class from cloudflare:workers.
Use this script as src/index.ts in the Worker project. It needs no bindings.
import { WorkerEntrypoint } from "cloudflare:workers";
function escapeHtml(text) {
return text
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
export class GatewayMiddleware extends WorkerEntrypoint {
async handle(request, context, next) {
const response = await next(request);
const contentType = response.headers.get("content-type") ?? "";
if (
request.method !== "GET" ||
response.status !== 200 ||
response.headers.has("content-encoding") ||
!response.body ||
!/^text\/html\b/i.test(contentType)
) {
return response;
}
const identity = await context.identity().catch(() => null);
const label = escapeHtml(identity?.email ?? "Unidentified user");
const headers = new Headers(response.headers);
for (const name of [
"content-length",
"etag",
"content-md5",
"digest",
"content-digest",
"repr-digest",
]) {
headers.delete(name);
}
headers.set("cache-control", "private, no-store");
const rewritten = new HTMLRewriter()
.on("body", {
element(element) {
element.prepend(
`<aside style="position:fixed;bottom:1rem;right:1rem;z-index:2147483647;pointer-events:none">Viewed by ${label}</aside>`,
{ html: true },
);
},
})
.transform(
new Response(response.body, { status: response.status, headers }),
);
return rewritten;
}
}import { WorkerEntrypoint } from "cloudflare:workers";
import type {
GatewayMiddlewareContext,
GatewayMiddlewareNext,
} from "@cloudflare/zt-types";
function escapeHtml(text: string): string {
return text
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
export class GatewayMiddleware extends WorkerEntrypoint {
async handle(
request: Request,
context: GatewayMiddlewareContext,
next: GatewayMiddlewareNext,
): Promise<Response> {
const response = await next(request);
const contentType = response.headers.get("content-type") ?? "";
if (
request.method !== "GET" ||
response.status !== 200 ||
response.headers.has("content-encoding") ||
!response.body ||
!/^text\/html\b/i.test(contentType)
) {
return response;
}
const identity = await context.identity().catch(() => null);
const label = escapeHtml(identity?.email ?? "Unidentified user");
const headers = new Headers(response.headers);
for (const name of [
"content-length",
"etag",
"content-md5",
"digest",
"content-digest",
"repr-digest",
]) {
headers.delete(name);
}
headers.set("cache-control", "private, no-store");
const rewritten = new HTMLRewriter()
.on("body", {
element(element) {
element.prepend(
`<aside style="position:fixed;bottom:1rem;right:1rem;z-index:2147483647;pointer-events:none">Viewed by ${label}</aside>`,
{ html: true },
);
},
})
.transform(
new Response(response.body, { status: response.status, headers }),
);
return rewritten;
}
}The script escapes the user label before inserting it into HTML. It removes stale length and integrity headers and marks the personalized response as non-cacheable. Other response headers, including Content Security Policy, are preserved.
This template deliberately allows anonymous traffic. It uses Unidentified user when identity is null or Gateway cannot retrieve identity.
Deploy the Worker and create a Forward policy for your test application's HTML pages and a test user group. Visit the page and confirm that the label contains your Gateway identity email. Test an image or JSON endpoint and confirm its body is unchanged.
A page's Content Security Policy can prevent the inline style from being applied. The label will still be inserted, but its appearance depends on the page. Configure styling in your own application rather than weakening its security policy. Client-side rendering can also remove injected elements.
This template changes uncompressed HTML with a 200 response only. Configure your test origin to serve uncompressed HTML. It does not watermark PDF files, images, or other binary downloads, and a visible label can be removed by the user. Test authentication flows and application rendering before widening the policy scope.