# CF-Workers-TGProxy: a Telegram web proxy backend as a single Cloudflare Worker

> CF-Workers-TGProxy is one JavaScript file, telegram_webproxy.js, that acts as the backend for Telegram's own web proxy, forwarding WebSocket lanes from the client's webview to Telegram's Web K endpoint without a separate MTProxy server. It trades a Durable Object for per-stream lanes and a token lease system, and the performance numbers in its README are explicitly labelled as a theoretical model rather than measurements.

**ToiCF/CF-Workers-TGProxy** — 基于 Cloudflare Workers 的 Telegram Web/MTProto WebSocket 代理中继，支持 bridge 鉴权、bootstrap/session 令牌、多 lane WS 复用、AES-CTR 加密、自定义帧、流控与严格 CSP。

- Repository: https://github.com/ToiCF/CF-Workers-TGProxy
- Stars: 1,012 · Forks: 362
- Language: Unknown
- License: GPL-3.0
- Published: 2026-10-20 · Updated: 2026-10-20 · Language: en
- Canonical page: https://hysenlabs.com/projects/toicf-cf-workers-tgproxy

## One JavaScript file standing in for a Telegram proxy server

The entire implementation is a single file, `telegram_webproxy.js`, in a repository that also contains a README and a LICENSE. That is the whole project, and it is a deliberate design rather than an early-stage repository.

What it does is play the part of the backend for Telegram's official web proxy, the one the Desktop and Android clients use when they load a proxy page in an isolated webview. Telegram's normal model expects a separate server to sit between the client and the MTProxy infrastructure: the client connects to a local or self-hosted service, and that service forwards onwards. This project removes that server. The Worker accepts the client's WebSocket lanes and dials Telegram's own Web K channel directly, at the `kws{dc}[-1].web.telegram.org/apiws` endpoints, using the `binary` subprotocol.

The flow the README documents has four steps. The webview page posts to `/api/v1/session` with a bootstrap value and receives a session token along with an `X-Carrier-Mode: websocket-lanes` header. The client then opens one lane per Telegram stream at `/api/v1/ws` with the subprotocol `tproxy-lane-v1.<token>.<sid>`. Inside the Worker, the lane is de-obfuscated, re-headed and dialed out to the KWS endpoint, and from then on the Worker relays bytes.

There is also an Android-specific piece: an in-app bridge exposed as `globalThis.TelegramWebProxy`, which passes messages in both directions over `postMessage` between the Android app and the page inside the webview.

The audience is narrow and technical: someone who already deploys Cloudflare Workers and wants a proxy backend to run at the edge rather than on a machine they have to keep alive.

## The KWS switch keeps the payload end-to-end encrypted

The change the README leads with is the move away from a self-hosted MTProxy or middle proxy. In the classic web proxy path there is a chain of client, local service, your own relay server, then MTProxy, and that relay has to terminate and re-encrypt at the MTProto transport layer.

This implementation removes the middle. The Worker unwraps the client's MTProto transport obfuscation locally and re-wraps it with a key it generates itself, then dials Telegram's official Web K entry point. The README is explicit about the boundary that matters: the inner MTProto payload is always end to end encrypted by the client, and the Worker neither sees nor performs MTProto level encryption or decryption. The only payload the Worker touches is the 64 byte obfuscation header, which it decodes once and re-signs once.

The obfuscation modes it re-signs are listed as `0xef`, `0xee` and `0xdd`, across data centres 1 to 5, with a media flag. The AES-CTR transport key derivation comes from the `SECRET` binding, and the same secret also signs the `?bridge=` capability query that the page route checks.

The practical consequence for the operator is that the outbound connection lands on an official Telegram web domain rather than on a third party address, which matters both for reachability and for the fact that the edge layer treats unknown destinations inconsistently. The README records a concrete case of this: a reference implementation that pointed at a non-official `web.telegram.org` domain produced edge errors `1034` and `403`, and switching to the official web domain restored normal behaviour. The advice it gives is to prefer Telegram's official web domains and a stable own CNAME or Custom Domain rather than an intermediary route of uncertain quality.

## Lanes instead of Durable Objects

The most interesting engineering decision in the project is what it does not use.

Public Workers web proxy implementations generally reach for a Durable Object to hold a single long-lived connection and its state. The README makes the argument against that plainly: a Durable Object is single threaded and has to be routed to a fixed instance, so the limits and the queuing all pile onto that one object.

This implementation splits the work per stream instead. Every Telegram stream becomes its own WebSocket, and the stream identifier travels in the subprotocol as the `sid` part of `tproxy-lane-v1.<token>.<sid>`. All lanes still land in the same isolate, and the session and lease state is a `Map` held inside that isolate, so there is no cross-instance coordination to implement.

The limits are explicit and generous by design: 128 lanes per session, a per-lane cap of `laneMax` at 8MB and `laneItems` at 1024 entries, a whole-session cap of `qMax` at 32MB and `iMax` at 16384 items, and a ceiling of 4096 used `sid` values.

The reasoning behind it is that the Telegram client already opens multiple streams and splits its own traffic, so giving each stream its own backpressure and its own queue is a better fit than funnelling an entire session into one single-threaded object. The stated benefit is that the client does not feel a single connection's rate limit, and that no Durable Object is needed at all.

The cost is that all lanes for a session must reach the same isolate to share the state `Map`. The README does not discuss what happens when lanes for one session land on different isolates, which is the question a careful operator would want answered before relying on the session state across many concurrent streams.

## Bootstrap, session token and a lease that can be deleted

Access is a three-step token flow, and the expiry values are short.

The page is served with a one-time `nonce` and a short-lived bootstrap value, produced by `mkBoot`, valid for 2 minutes. The client posts that bootstrap to `/api/v1/session`, and if it validates, the Worker issues a session token from `mkSess` with a 5 minute lifetime. Each session token maps to a lease record that tracks which `sid` values are active, which have been used, when the lease expires, and whether it has been deleted.

Deleting a session is a `DELETE` to the same path, which sets the deleted flag on the lease, and any later lane attempt on that session gets a `409` or is closed. The tokens themselves are signed with a truncated HMAC-SHA256, and both signing and comparison run in constant time, which is the right way to handle a value that gates access.

Page isolation is enforced with a strict content security policy: `default-src 'none'`, a sandbox of `allow-same-origin allow-scripts`, and `frame-ancestors http://127.0.0.1:*` so the page can only be framed from a local address. On the WebSocket route, a request that carries an `Origin` header must be same-origin with the Worker, otherwise it is rejected.

The lease accounting is what keeps a token from being reused to open lanes indefinitely, and the short session TTL is a deliberate trade. An operator who needs a long-lived client session will have to look at how the client handles re-authentication rather than assume the token is long lived.

## Bundling small frames and refusing WebSocket compression

Two data-plane decisions stand out, and both are about the cost of moving small pieces of data.

The first is bundling. Traffic in both directions is collected and merged before it is written, with an upload bundle target of `upPack` at 20KB and a downstream aggregation ceiling of `dnPack` at 32KB, and a downstream quiet window of `dnMs` at 1 millisecond that decides when to flush. The README describes the intent as cutting the fixed scheduling cost of frequent small frames rather than building a heavy queue, and the pseudocode is short enough to be worth reading in full: the upload path collects and bundles before writing to the peer, and the download path collects and bundles before the `dnPack` gate hands the result to the WebSocket send.

The second decision is to refuse compression. The handshake response sets `Sec-WebSocket-Extensions` to empty explicitly. The argument is that the relay carries a raw TCP byte stream, which internally is often high-entropy data such as TLS or HTTP2, so compressing it again in the Worker buys nothing reliable while raising CPU cost on the hot path. It is treated as a negative optimisation on purpose.

Both of these are Worker-specific reasoning, which is the point: the tuning constants assume a Workers isolate and its scheduling behaviour, and they are not portable numbers for a proxy running on a rented VM.

The README also has a performance section, and it is careful about its own claims. The figures are marked as a theoretical model with an inferred tag and are explicitly stated not to be measured benchmarks of this repository. The improvement quoted for removing the middle hop is roughly 2x in a bottleneck scenario, counting only the decryption work and the removed round trip, and the page cites a replay range of 1.8x to 39.8x that it attributes to a separate TCP project. Treat all of it as a design argument rather than a measurement you can rely on.

## Deploying it: two bindings, five routes, one file

Deployment is a Cloudflare Workers deployment of a single script, and the README gives the two bindings you must set.

`SECRET` is the proxy key. It is used both for deriving the AES-CTR transport key and for signing the `?bridge=` capability. The format is 32 hex characters, or one of the `dd`, `ee` or `ef` prefixes followed by 32 hex characters, which makes 34 in total, using the character set `0-9a-f`. The comparison is case-insensitive, and the recommendation is lowercase.

`BOOT` is the bootstrap token used to issue bootstrap and session values, and it is inlined into the page the Worker serves. The only stated requirement is that it is not empty, with a recommendation of a random string of at least 16 characters.

The three generation commands the README gives are:

```bash
openssl rand -hex 16
printf 'dd%s\n' "$(openssl rand -hex 16)"
openssl rand -hex 24
```

The first produces the 32 character hex `SECRET`, the second produces the 34 character prefixed variant, and the third produces a 48 character hex value suitable for `BOOT`. The `openssl rand` command draws from the system CSPRNG, so the values are genuinely random. The important operational detail is what happens when a binding is missing: `mkCfg` throws, and the health route returns a `500` instead of the expected success object. In other words, a missing secret shows up as a failing health check rather than as a subtle misconfiguration, which is a good failure mode to have.

The routes are short. `/` accepts `GET` and `HEAD` and serves the web proxy page after validating the bridge signature. `/health` answers `GET` with `{"ok":true}`. `/api/v1/session` accepts `POST` to create a session and `DELETE` to destroy one. `/api/v1/ws` accepts `GET` for a lane WebSocket and requires both an `Upgrade` header and the correct subprotocol.

For access quality, the README recommends using your own domain with a CNAME, or Cloudflare SaaS custom hostnames, so that connections land on a stable hostname you control.

## What it does not do, and the state of the project

There are real limits to this approach, and the first one is about the code itself. The project is a single file with no package manifest, no dependency list, no test directory and no build tooling in the repository. A file of that size that implements obfuscation handling, token issuance, lane routing and a relay is a large amount of logic in one place with no visible test suite to back it, and anyone who needs to modify it is editing it without a safety net.

The second limit is structural. The Worker terminates the client's transport obfuscation, which means it holds a secret that lets it re-sign traffic. The project states that the inner payload stays end to end encrypted, and the design does keep the MTProto layer untouched, but the deployment still holds key material on Cloudflare's infrastructure. A security review of this setup is not the same as reviewing a plain reverse proxy.

The third is the edge. Everything depends on the Worker being able to reach `kws{1..5}[-1].web.telegram.org/apiws` reliably, and the README's own account of `1034` and `403` errors shows how a change on the Telegram side or an unlucky route can break the whole chain with no code change at all. There is no fallback path described.

On the project state: the licence is GPL-3.0, which is a real constraint for anyone who wants to embed this code in a closed source product, and the README links a Telegram channel for discussion rather than an issue tracker with a stated process. The last push to the repository was on 2026-10-11, the default branch is `main`, and there are no GitHub releases, so there is no version to pin and no changelog to diff. The primary language is not recorded for the repository either. Everything you deploy is whatever is on `main` at the moment you copy it, and the only upgrade path is to watch that branch.

## Conclusion

CF-Workers-TGProxy fits an operator who already runs Cloudflare Workers and wants the Telegram web proxy backend to live at the edge instead of on a rented server, and who is comfortable owning a single JavaScript file with no upstream releases to track. It is the wrong choice if you need a stable, versioned project with a support channel, since the repository has no GitHub releases at all, or if your network path to kws web endpoints is already open, since the whole point is the extra hop it removes. Verify three things before deploying: that the SECRET and BOOT bindings are set, because a missing binding makes the health check return a 500 instead of the expected ok response, that your own domain resolves to the Worker through a CNAME or a Cloudflare custom hostname, and that using the project complies with Telegram's terms of service and with local law where you run it.

## FAQ

### What does CF-Workers-TGProxy do?

It is a single file Cloudflare Worker that acts as the backend for Telegram's official web proxy, forwarding WebSocket lanes from the client's webview to Telegram's own KWS endpoint. It no longer needs a separate MTProxy or middle proxy server, because the Worker re-signs the transport obfuscation itself.

### Does the Cloudflare Worker see my Telegram messages?

The README states the inner MTProto payload is always encrypted end to end by the client and that the Worker neither touches nor decrypts the MTProto layer. The Worker only decodes and re-signs the 64 byte obfuscation header.

### What secrets do I need to set before deploying CF-Workers-TGProxy?

Two bindings, SECRET and BOOT. SECRET must be 32 hex characters or a dd, ee or ef prefix followed by 32 hex characters, and BOOT must be a non-empty random string of at least 16 characters. If either is missing, the health route returns a 500 instead of the ok response.

### How does CF-Workers-TGProxy authenticate a session?

A one-time bootstrap value with a 2 minute lifetime is posted to /api/v1/session, and on success the Worker issues a session token valid for 5 minutes, signed with a truncated HMAC-SHA256 and compared in constant time. Each token maps to a lease that tracks active and used stream ids and can be deleted to make later lanes fail with a 409.

### Does CF-Workers-TGProxy use Durable Objects?

No. The README explains that it splits traffic into per-stream WebSocket lanes that all stay in the same isolate, with session and lease state held in a Map inside that isolate, to avoid the single-threaded routing and queuing limits of a Durable Object.

### How is CF-Workers-TGProxy licensed?

The repository is licensed under GPL-3.0, and the README links the licence file in the repository root. There are no GitHub releases, so there is no tagged version to pin, and the last push to the repository was on 2026-10-11.

## Sources

- [Issues](https://github.com/ToiCF/CF-Workers-TGProxy/issues)
- [License: GPL-3.0](https://github.com/ToiCF/CF-Workers-TGProxy/blob/main/LICENSE)
- [README](https://github.com/ToiCF/CF-Workers-TGProxy/blob/main/README.md)
- [ToiCF/CF-Workers-TGProxy on GitHub](https://github.com/ToiCF/CF-Workers-TGProxy)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/toicf-cf-workers-tgproxy
