A free tier with no login, that isn't a free proxy for everyone else

Three layers of abuse limits for traffic nobody pays for - signups per IP, renders per target domain, and a budget measured in slot-seconds - plus the billing bypass I shipped to production and found by accident.

Screenshotline's landing page has a demo that renders a real page with no signup, no key and no card. That was a deliberate decision: if you make people create an account to find out whether your product works, most of them simply don't.

It is also an invitation to burn someone else's CPU. Here is what it takes to offer that and not become a free rendering service for every scraper on the internet, including the hole I shipped to production myself.

The thing being spent is not money

The hosted box is a fixed monthly price and renders one page at a time. Free traffic therefore does not cost dollars. It costs slot time that a paying customer was about to need.

That reframing changes every limit. The question stops being "how much can I afford to give away?" and becomes "how much of the queue may people who aren't paying occupy, and who waits behind whom?" The queue answers the second part: paid requests jump ahead of free and demo ones. The limits below are for the first.

None of this applies if you self-host. A self-hosted deployment has no free plan and never reaches this code.

Layer 1: signups per IP

The tempting first limit is a per-IP rate limit on the demo. It is also the least important one.

The real hole is account creation. A free key is worth 500 renders a month, and signup needs no card and no confirmation email, both deliberate and both good for conversion. So one script minting keys in a loop is worth vastly more free compute than anyone hammering the demo, which is limited to 5 renders a minute per IP.

Three signups per IP per day. That's the limit that matters most, and it's the one people think of last.

Layer 2: renders per target domain

This one isn't about protecting my server at all.

If the free tier can be pointed at one third-party site repeatedly, then my server becomes the thing hammering that site. The consequence lands on my paying customers: my IP goes onto the bot-wall lists, and captures of that site start coming back as challenges for everyone, including the people who pay.

So free renders are capped per target domain, per minute. And here is where I got the number wrong first.

I set it to 10 a minute, which felt cautious and reasonable. Then I noticed the landing page demo box is pre-filled with https://example.com. Every visitor who clicks "Try it" without editing the field renders the same domain. On a launch-day crowd they all share one bucket, and a limit meant to stop abuse would have rate-limited my own demo against itself, on the one day traffic actually arrives.

It's 30 now. The lesson generalises: before setting any shared limit, look at what the most common input actually is. A default value in a form field is a traffic pattern.

Layer 3: a budget in seconds, not requests

Per-IP limits fall over against anyone with a proxy list. So the backstop is a global budget shared by all free and demo traffic: a share of total pool time per rolling hour, currently 0.75, with any single free caller limited to 0.2 of that.

Two details make it work.

It's measured in slot-seconds, not renders. A render is not a unit of cost here. One that runs to the timeout costs roughly ten ordinary ones, and a free key sending deliberately slow pages is never billed and never touches its quota, because failures aren't charged. Counting requests would price that attack at zero.

It doesn't care who is asking. That's the point of a global budget: proxy rotation doesn't help you, because the budget is shared by everyone who isn't paying.

It's all in-process and resets on restart, which is fine. A restart forgives at most one window, and it keeps the database as the only state that has to survive a deploy.

0.75 is generous on purpose

Three quarters of the hour available to people who aren't paying looks careless until you remember the box costs the same whether it's busy or idle. Unused capacity is not saved money. It's wasted capacity.

An earlier version used 50%, which sounded prudent. Played forward against a launch-day spike, it would have switched the demo off mid-surge to protect paying customers who did not exist yet, turning off the thing that creates customers in order to protect customers.

The lever still exists for when it's needed: FREE_SLOT_SHARE=0.3 in the environment and a restart, or 0 to turn free rendering off entirely. Decide limits by what they cost on the day you get traffic, not by what sounds responsible in a config file.

The bypass I shipped

The demo route is /demo, and it carries its own fences: no key needed, 5 per IP per minute, hourly budget, never metered. The main route is /take, which needs a key and meters usage.

Internally, both funnelled into the same handler, and that handler decided whether the request was a demo by reading the option off the parsed request options, which for /take came from the caller's own query string.

/take?url=https://example.com&demo=true

No key. No quota check. No metering. And none of the demo's own fences either, because those live on the /demo route, not in the flag. I probed it against live production: 200, a real PNG, no quota headers. A complete billing bypass, publicly reachable.

Three things about how it was found and fixed are worth carrying away.

It was found by accident. I was wiring up the abuse limits above and noticed the flag had two sources. No security review found it, because nobody was looking.

Every test suite passed. The smoke suite runs with billing off, which is the right way to test rendering and the exact configuration in which this bug is invisible. There is now a separate suite that boots with billing on.

The fix is a shape, not a patch. The demo flag is read only from the route's own forced options, never from caller input. A privileged mode that can be requested is not a mode, it's a parameter.

And one habit that isn't optional: after writing the regression tests, I put the bug back and confirmed they failed. A test you have never seen fail is not evidence of anything.

If you're building a free tier

  1. Rate-limit account creation before you rate-limit usage. Minting keys is worth more than any single key.
  2. Cap by target as well as by caller, if your product touches third parties on their behalf. Your reputation is a shared resource.
  3. Meter cost, not requests, when a slow request costs ten times a fast one.
  4. Have a global backstop that proxy rotation cannot route around.
  5. Check your own defaults before choosing a number. A pre-filled form field concentrates traffic.
  6. Test with billing on. The most dangerous bugs live in the configuration your tests don't use.

The limits are in src/limits.js, with the reasoning in comments, and the free plan is 500 renders a month if you'd rather just use it.

Get a free key — 500 renders a month Try it without signing up

More from the blog