[{"data":1,"prerenderedAt":424},["ShallowReactive",2],{"blog-\u002Fblog\u002Flicensing-without-a-database":3,"blog-more-\u002Fblog\u002Flicensing-without-a-database":399},{"id":4,"title":5,"author":6,"body":7,"cover":388,"date":389,"description":390,"extension":391,"meta":392,"navigation":393,"path":394,"readingTime":94,"seo":395,"stem":396,"tag":397,"__hash__":398},"blog\u002Fblog\u002Flicensing-without-a-database.md","Licensing without a database","The CrawlStat team",{"type":8,"value":9,"toc":378},"minimark",[10,23,31,39,42,47,50,57,98,101,105,108,210,217,220,224,227,255,258,265,269,272,275,289,300,303,307,310,314,317,320,324,330,333,336,341,344,348,351,374],[11,12,14],"callout",{"type":13},"note",[15,16,17,18,22],"p",{},"CrawlStat is now free and open source, under the MIT licence. The official build never asks for a key, and every feature is included for everyone. This post describes the licence code that is still in the repository, in ",[19,20,21],"code",{},"engine\u002Fsrc\u002Flicense.rs",", for anyone who wants to sell their own builds.",[15,24,25,26,30],{},"Licensing is the part of a desktop app that users notice only when it breaks. It usually breaks at the worst moment: on a plane, on hotel wifi, or when the vendor's licence server is having a bad day and suddenly ",[27,28,29],"em",{},"your"," afternoon is too.",[15,32,33,34,38],{},"We had one rule for the licence code: ",[35,36,37],"strong",{},"a crawl never waits on a server, and never fails because of it."," We also didn't want to run a licence database. That means no table of activations, no backups, no migrations, and nobody on call for a service whose only job is to say yes.",[15,40,41],{},"Here's how both turned out to be possible, and what we gave up to get there.",[43,44,46],"h2",{"id":45},"the-payment-provider-already-knows-who-paid","The payment provider already knows who paid",[15,48,49],{},"Every licensing system needs a source of truth for \"has this person paid?\". There already is one: the merchant of record that takes the payment. It stores the customer, the subscription, refunds and cancellations. Copying that into a second database would only give you a second copy that can disagree with the first.",[15,51,52,53,56],{},"So the licence side is deliberately thin. A small licence service sits in front of the provider and answers one question. When the provider confirms a licence is valid, the service issues a short lived, digitally signed ",[35,54,55],{},"entitlement token",". The app knows how to check that signature by itself, without asking anyone.",[58,59,64],"pre",{"className":60,"code":61,"language":62,"meta":63,"style":63},"language-txt shiki shiki-themes github-light","  app ──activate(key)──▶ licence service ──▶ merchant of record\n                                │\n                       signs an entitlement\n                                ▼\n  app ◀───── token (14 days) ───┘        app verifies offline from here on\n","txt","",[19,65,66,74,80,86,92],{"__ignoreMap":63},[67,68,71],"span",{"class":69,"line":70},"line",1,[67,72,73],{},"  app ──activate(key)──▶ licence service ──▶ merchant of record\n",[67,75,77],{"class":69,"line":76},2,[67,78,79],{},"                                │\n",[67,81,83],{"class":69,"line":82},3,[67,84,85],{},"                       signs an entitlement\n",[67,87,89],{"class":69,"line":88},4,[67,90,91],{},"                                ▼\n",[67,93,95],{"class":69,"line":94},5,[67,96,97],{},"  app ◀───── token (14 days) ───┘        app verifies offline from here on\n",[15,99,100],{},"After that, a verified token is proof on its own.",[43,102,104],{"id":103},"whats-in-a-token","What's in a token",[15,106,107],{},"A token carries a small set of facts about the licence, plus a signature that proves who issued it. Roughly:",[58,109,113],{"className":110,"code":111,"language":112,"meta":63,"style":63},"language-json shiki shiki-themes github-light","{\n  \"plan\": \"pro\",\n  \"issued_at\": 1790000000,\n  \"expires_at\": 1791209600,\n  \"key_fingerprint\": \"3f9a1c07\",\n  \"features\": [\"unlimited_urls\", \"crawl_comparison\", \"xlsx_export\", \"…\"]\n}\n","json",[19,114,115,121,137,149,161,173,204],{"__ignoreMap":63},[67,116,117],{"class":69,"line":70},[67,118,120],{"class":119},"sgsFI","{\n",[67,122,123,127,130,134],{"class":69,"line":76},[67,124,126],{"class":125},"sYu0t","  \"plan\"",[67,128,129],{"class":119},": ",[67,131,133],{"class":132},"sYBdl","\"pro\"",[67,135,136],{"class":119},",\n",[67,138,139,142,144,147],{"class":69,"line":82},[67,140,141],{"class":125},"  \"issued_at\"",[67,143,129],{"class":119},[67,145,146],{"class":125},"1790000000",[67,148,136],{"class":119},[67,150,151,154,156,159],{"class":69,"line":88},[67,152,153],{"class":125},"  \"expires_at\"",[67,155,129],{"class":119},[67,157,158],{"class":125},"1791209600",[67,160,136],{"class":119},[67,162,163,166,168,171],{"class":69,"line":94},[67,164,165],{"class":125},"  \"key_fingerprint\"",[67,167,129],{"class":119},[67,169,170],{"class":132},"\"3f9a1c07\"",[67,172,136],{"class":119},[67,174,176,179,182,185,188,191,193,196,198,201],{"class":69,"line":175},6,[67,177,178],{"class":125},"  \"features\"",[67,180,181],{"class":119},": [",[67,183,184],{"class":132},"\"unlimited_urls\"",[67,186,187],{"class":119},", ",[67,189,190],{"class":132},"\"crawl_comparison\"",[67,192,187],{"class":119},[67,194,195],{"class":132},"\"xlsx_export\"",[67,197,187],{"class":119},[67,199,200],{"class":132},"\"…\"",[67,202,203],{"class":119},"]\n",[67,205,207],{"class":69,"line":206},7,[67,208,209],{"class":119},"}\n",[15,211,212,213,216],{},"One detail saved us a whole class of bugs. The signature covers the token ",[27,214,215],{},"exactly as it was sent",", byte for byte, not a rebuilt copy of its contents. If it covered a rebuilt copy, any tiny difference in formatting between the side that signs and the side that checks would make every token invalid. Signing the exact bytes that travel means nothing can be reformatted on the way.",[15,218,219],{},"We still don't trust it blindly. The test suite checks a real token produced by the real signing code. If the two sides ever drift apart, the build fails instead of every customer's activation.",[43,221,223],{"id":222},"a-fortnight-of-offline-by-design","A fortnight of offline, by design",[15,225,226],{},"Here's how the timing works:",[228,229,230,238,244,247,252],"ul",{},[231,232,233,234,237],"li",{},"A token is valid for ",[35,235,236],{},"14 days",".",[231,239,240,241,237],{},"The app refreshes it ",[35,242,243],{},"about once a day",[231,245,246],{},"If a refresh fails, it retries with growing gaps, at most once a day.",[231,248,249],{},[35,250,251],{},"A crawl that's already running is never checked again.",[231,253,254],{},"Only after fourteen days of failed refreshes does the install fall back to its unlicensed limits.",[15,256,257],{},"\"Falls back\" means exactly that. The app keeps working, just with the limits of an unlicensed install. Nothing gets locked, nothing gets deleted, and your crawl files stay yours. A paying customer on a long flight, or in an office with a strict firewall, keeps their features the whole time.",[15,259,260,261,264],{},"Who protects the customer during an outage matters here. The licence service ",[35,262,263],{},"fails closed",": if the payment provider can't be reached, it answers \"not valid\", never \"probably fine\". The app's grace period is what protects a paying customer during an outage, not a server making a hopeful guess.",[43,266,268],{"id":267},"enforcement-lives-in-the-crawler","Enforcement lives in the crawler",[15,270,271],{},"A URL limit isn't enforced by the interface hiding a button. The crawler itself caps the number of URLs for each crawl, by reading the licence state as the crawl is set up. Every way of starting a crawl hits the same limit, because they all go through the same code.",[15,273,274],{},"Two defaults are worth calling out:",[228,276,277,283],{},[231,278,279,282],{},[35,280,281],{},"The safe state is the limited one."," If something goes wrong loading a licence, the app ends up limited, not unlimited by accident. When this goes wrong, it should go wrong in that direction.",[231,284,285,288],{},[35,286,287],{},"The cap is saved with the crawl",", so resuming a crawl doesn't get around the limit either.",[15,290,291,292,295,296,299],{},"Tokens grant ",[35,293,294],{},"features",", not plan names. The app checks for ",[19,297,298],{},"unlimited_urls",", not for what the plan is called. So a downgraded or partly refunded licence can't grant more than it should. The licence service can also grant a feature an older version of the app has never heard of, and that version won't break.",[15,301,302],{},"In the official open source build, this is where everything gets switched on: the default state grants every feature to everyone, so the same code path simply never finds a limit.",[43,304,306],{"id":305},"secrets-stay-locked-away","Secrets stay locked away",[15,308,309],{},"The licence key and the signed token live in your operating system's secure keychain. Only the core of the app, the part that crawls, ever reads them. The interface asks \"am I licensed?\" and gets a yes or no back. A bug in the interface can't leak either secret, and neither one can turn up in a crash report from it.",[43,311,313],{"id":312},"what-it-costs-to-run","What it costs to run",[15,315,316],{},"Very little, and that was the point. Each install checks in about once a day. Ten thousand customers works out to a few hundred thousand small requests a month, well inside a free hosting allowance.",[15,318,319],{},"There's no database to back up, because there's no database. The payment provider sits behind a small interface, so switching providers is a change of settings, not a rewrite.",[43,321,323],{"id":322},"the-tradeoff-we-chose","The tradeoff we chose",[15,325,326,327],{},"This design has a real cost, and it's worth spelling out: ",[35,328,329],{},"revoking a licence isn't instant.",[15,331,332],{},"If someone gets a refund, their install finds out at its next check in. Usually that's within a day. If the machine stays offline the whole time, it can take up to the full fourteen days. During that window, a refunded install can keep its paid features.",[15,334,335],{},"The only way to close that window is a revocation list the app checks. A revocation list is a database, plus a server the app has to reach, which brings back exactly what we set out to remove. For a small desktop tool, we think that's the wrong trade. So we accepted the window on purpose, not by accident. If that ever changes, there's a natural place for a revocation list to plug in.",[11,337,338],{"type":13},[15,339,340],{},"Rate limiting gets the same honesty. It's deliberately simple, so a determined attacker could get more requests through than the configured limit. What it reliably stops is the realistic failure: an app stuck in a retry loop, or a script hammering one key until it turns into a bill. If the numbers ever justify something stricter, it can be swapped in without changing the app.",[15,342,343],{},"Can someone patch a desktop app to skip all of this? With enough effort, people can patch any desktop software. We'd rather build licensing that never gets in an honest customer's way than one that punishes every customer to slow down the few who'd never pay anyway.",[43,345,347],{"id":346},"the-result","The result",[15,349,350],{},"With this design, starting a crawl never talks to a server first. There's no spinner while a licence gets checked, and no \"unable to verify\" dialog. A paid licence renews quietly in the background.",[15,352,353,354,362,363,367,368,373],{},"In the official build there's nothing to renew at all, because there's no key. If you sell your own builds, the code is all in ",[355,356,360],"a",{"href":357,"rel":358},"https:\u002F\u002Fgithub.com\u002Fclotairedamy\u002FCrawlStat\u002Fblob\u002Fmain\u002Fengine\u002Fsrc\u002Flicense.rs",[359],"nofollow",[19,361,21],{},", with its tests. Otherwise, ",[355,364,366],{"href":365},"\u002Fdownload","download CrawlStat"," and start crawling, or read the ",[355,369,372],{"href":370,"rel":371},"https:\u002F\u002Fgithub.com\u002Fclotairedamy\u002FCrawlStat",[359],"code on GitHub"," to see how it all fits together.",[375,376,377],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sgsFI, html code.shiki .sgsFI{--shiki-default:#24292E}html pre.shiki code .sYu0t, html code.shiki .sYu0t{--shiki-default:#005CC5}html pre.shiki code .sYBdl, html code.shiki .sYBdl{--shiki-default:#032F62}",{"title":63,"searchDepth":76,"depth":76,"links":379},[380,381,382,383,384,385,386,387],{"id":45,"depth":76,"text":46},{"id":103,"depth":76,"text":104},{"id":222,"depth":76,"text":223},{"id":267,"depth":76,"text":268},{"id":305,"depth":76,"text":306},{"id":312,"depth":76,"text":313},{"id":322,"depth":76,"text":323},{"id":346,"depth":76,"text":347},null,"2026-09-30","An engineering story. How the licence code in CrawlStat checks a signed token offline, so a crawl never waits on a server, and the delay we accepted on purpose to get there.","md",{},true,"\u002Fblog\u002Flicensing-without-a-database",{"title":5,"description":390},"blog\u002Flicensing-without-a-database","Engineering","rEhJiDfGhk49L2hPjzdmWuG4igkAl3ql69B0ayPAlyY",[400,405,410,414,418,423],{"title":401,"path":402,"date":403,"tag":404,"readingTime":94},"CrawlStat is now free and open source","\u002Fblog\u002Fcrawlstat-is-open-source","2026-11-04","News",{"title":406,"path":407,"date":408,"tag":409,"readingTime":88},"Client reports, JavaScript rendering and a new way around a crawl","\u002Fblog\u002Fclient-reports-and-javascript-rendering","2026-10-26","Announcement",{"title":411,"path":412,"date":413,"tag":409,"readingTime":175},"Introducing CrawlStat","\u002Fblog\u002Fintroducing-crawlstat","2026-10-21",{"title":415,"path":416,"date":417,"tag":397,"readingTime":175},"The database is the frontier: how CrawlStat keeps memory flat","\u002Fblog\u002Fflat-memory-crawling","2026-10-14",{"title":419,"path":420,"date":421,"tag":422,"readingTime":175},"Redirect chains: the chain is the finding","\u002Fblog\u002Fredirect-chains-the-chain-is-the-finding","2026-10-07","SEO guide",{"title":5,"path":394,"date":389,"tag":397,"readingTime":94},1793827598367]