<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Koray KÖYLÜ</title>
    <description>The latest articles on DEV Community by Koray KÖYLÜ (@koraykoylu).</description>
    <link>https://dev.to/koraykoylu</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3956907%2F5240262c-b071-4103-98d3-9257c11f4c4f.png</url>
      <title>DEV Community: Koray KÖYLÜ</title>
      <link>https://dev.to/koraykoylu</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/koraykoylu"/>
    <language>en</language>
    <item>
      <title>npm trust github Fails with 400 Bad Request: The Missing Permission Flag</title>
      <dc:creator>Koray KÖYLÜ</dc:creator>
      <pubDate>Sat, 22 Aug 2026 21:46:35 +0000</pubDate>
      <link>https://dev.to/koraykoylu/npm-trust-github-fails-with-400-bad-request-the-missing-permission-flag-4j5f</link>
      <guid>https://dev.to/koraykoylu/npm-trust-github-fails-with-400-bad-request-the-missing-permission-flag-4j5f</guid>
      <description>&lt;p&gt;We published a JavaScript client to npm this week, and wiring up &lt;strong&gt;trusted publishing&lt;/strong&gt; cost three failed attempts and two wasted two-factor approvals before the real cause turned up. The command failed with a bare &lt;code&gt;400 Bad Request&lt;/code&gt; and no explanation, in the CLI output and in the debug log alike. The fix is one flag. Finding out which flag took an hour, because the npm version that knows about it is the only thing that will tell you.&lt;/p&gt;

&lt;p&gt;If you landed here from that error, the short answer is at the top of the next section. The rest explains why it happens, which npm versions are affected, and the two other traps waiting on the same path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does npm trust github return 400 Bad Request?
&lt;/h2&gt;

&lt;p&gt;Because your npm CLI is older than 20 May 2026 and is sending a trust configuration that no longer satisfies the registry. On that date npm started requiring every trusted publisher configuration to name at least one allowed action, and it shipped the CLI flags for it the same day. An older CLI does not know the field exists, sends the old payload, and the registry rejects it without saying what is missing.&lt;/p&gt;

&lt;p&gt;The fix is to run a newer npm and pass the flag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm trust github &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--file&lt;/span&gt; publish.yml &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--repository&lt;/span&gt; your-org/your-repo &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--environment&lt;/span&gt; npm &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--allow-publish&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a current npm the same mistake is diagnosed properly. Instead of a 400 you get:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;npm error At least one permission flag is required (--allow-publish, --allow-stage-publish)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Which npm versions are affected?
&lt;/h2&gt;

&lt;p&gt;We pinned the boundary by running the same dry run against each version. It is exact:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;npm 11.14.1&lt;/strong&gt; (8 May 2026) and older: accepts the command, sends a payload with no permissions field, and the registry answers &lt;code&gt;400 Bad Request&lt;/code&gt; with no body.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;npm 11.15.0&lt;/strong&gt; (20 May 2026) and newer: refuses client side and names the missing flag.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The machine we hit this on had npm 11.11.0, shipped in February 2026, three months before the rule changed. That is recent enough to feel current and old enough to fail silently, which is the worst combination.&lt;/p&gt;

&lt;p&gt;Worth noting for anyone debugging this: nothing on the wire tells you either. The verbose log records &lt;code&gt;http fetch POST 400&lt;/code&gt; and the stack trace, and that is all. There is no error body to read, so the usual instinct of increasing log level does not help.&lt;/p&gt;

&lt;h2&gt;
  
  
  What if you cannot upgrade npm globally?
&lt;/h2&gt;

&lt;p&gt;This is a real corner. npm 12 requires Node 24.15.0 or newer, and if your Node is even slightly behind, the upgrade is refused:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;npm error notsup Not compatible with your version of node/npm: npm@12.0.2
npm error notsup Required: {"node":"^22.22.2 || ^24.15.0 || &amp;gt;=26.0.0"}
npm error notsup Actual:   {"node":"v24.14.1","npm":"11.11.0"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A global install can also need elevated permissions you would rather not use for a one off command. You do not have to install anything. Run a newer npm directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx &lt;span class="nt"&gt;-y&lt;/span&gt; npm@11.19.0 trust github &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--file&lt;/span&gt; publish.yml &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--repository&lt;/span&gt; your-org/your-repo &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--environment&lt;/span&gt; npm &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--allow-publish&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pick the newest npm that still supports your Node. The trust command talks to the registry, so it does not matter which npm your project normally uses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why can OIDC not publish the first version?
&lt;/h2&gt;

&lt;p&gt;This is the trap before the trap. Trusted publishing is configured from an existing package's settings page, so the package has to exist before OIDC can be attached to it, which means OIDC cannot perform a package's first publish. You publish version one manually, then attach the trusted publisher, and every release after that goes through CI with no token.&lt;/p&gt;

&lt;p&gt;If you have used PyPI, this will surprise you. PyPI lets you register a pending publisher for a name that does not exist yet, so the very first release can go out through OIDC with no manual step. npm has no equivalent.&lt;/p&gt;

&lt;p&gt;One thing to be careful about when you read up on this: npm's own trusted publishing documentation does not state the limitation either way. It simply assumes you already have a package. The constraint is documented in &lt;a href="https://github.com/npm/cli/issues/8544" rel="noopener noreferrer"&gt;npm/cli issue 8544&lt;/a&gt;, which is open, and the publish-once-then-attach workaround is what every third party guide recommends. Do not write it up as confirmed by the npm docs, because it is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why is the two-factor URL hidden?
&lt;/h2&gt;

&lt;p&gt;Both &lt;code&gt;npm publish&lt;/code&gt; and &lt;code&gt;npm trust&lt;/code&gt; require a second factor, and when npm cannot open a browser it prints the approval URL for you to open yourself. Except in a non-interactive shell it redacts it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;npm error This operation requires a one-time password.
npm error Open this URL in your browser to authenticate:
npm error   https://www.npmjs.com/auth/cli/***
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The redaction is in the debug log too, so there is nowhere to recover it from. If you are driving npm from a script, a CI shell, or any context without a TTY, allocate a pseudo-terminal and the URL comes through in full:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'\n'&lt;/span&gt; | script &lt;span class="nt"&gt;-q&lt;/span&gt; /dev/null npm publish &lt;span class="nt"&gt;--access&lt;/span&gt; public &lt;span class="nt"&gt;--auth-type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;web
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;printf&lt;/code&gt; answers npm's "Press ENTER to open in the browser" prompt, which makes it open the browser directly. Two practical notes: the approval link expires after a few minutes, so only run this when someone is actually at the keyboard, and the command sits waiting while it polls, so a short timeout will kill it before anyone can approve.&lt;/p&gt;

&lt;h2&gt;
  
  
  The sequence that works
&lt;/h2&gt;

&lt;p&gt;For a brand new package, start to finish:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create the public repository and add a publish workflow triggered on &lt;code&gt;release: published&lt;/code&gt;, with &lt;code&gt;id-token: write&lt;/code&gt; permission and an &lt;code&gt;environment&lt;/code&gt; set.&lt;/li&gt;
&lt;li&gt;Create that environment in the repository settings. It can be empty, with no protection rules.&lt;/li&gt;
&lt;li&gt;Publish version one manually with &lt;code&gt;npm publish --access public&lt;/code&gt;, approving the two-factor prompt.&lt;/li&gt;
&lt;li&gt;Attach the trusted publisher with &lt;code&gt;npm trust github&lt;/code&gt;, including &lt;code&gt;--allow-publish&lt;/code&gt;, approving two-factor again.&lt;/li&gt;
&lt;li&gt;Every release after that publishes itself. No token is stored anywhere and no second factor is requested.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Verify with &lt;code&gt;npm trust list &amp;lt;package&amp;gt;&lt;/code&gt;. A successful configuration prints the type, the repository, the workflow file, the environment and the permissions, so you can see exactly what the registry accepted.&lt;/p&gt;

&lt;p&gt;One last thing on the first publish, since you only get one shot at it looking right: run &lt;code&gt;npm publish --dry-run&lt;/code&gt; and read the file list. It is the cheapest way to catch a package that ships its source, its tests, or its lockfile because a &lt;code&gt;files&lt;/code&gt; entry was missing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why we were doing this
&lt;/h2&gt;

&lt;p&gt;The package that triggered all of this is &lt;a href="https://www.npmjs.com/package/@ibanchecker/client" rel="noopener noreferrer"&gt;@ibanchecker/client&lt;/a&gt;, the JavaScript and TypeScript client for the &lt;a href="https://ibanchecker.cash/api-docs" rel="noopener noreferrer"&gt;ibanchecker.cash API&lt;/a&gt;. It validates IBANs across 92 countries, resolves the bank behind an account, and looks up BIC codes, with no runtime dependencies and both ESM and CommonJS builds. There are &lt;a href="https://pypi.org/project/ibanchecker/" rel="noopener noreferrer"&gt;Python&lt;/a&gt; and &lt;a href="https://packagist.org/packages/ibanchecker/client" rel="noopener noreferrer"&gt;PHP&lt;/a&gt; clients as well, and those two publish through OIDC on their own registries, which is what made the npm difference so noticeable.&lt;/p&gt;

&lt;p&gt;If you just want to check an IBAN without installing anything, the &lt;a href="https://ibanchecker.cash" rel="noopener noreferrer"&gt;validator&lt;/a&gt; is free and needs no account.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://docs.npmjs.com/trusted-publishers/" rel="noopener noreferrer"&gt;npm Docs: trusted publishing&lt;/a&gt;, including allowed actions and the 20 May 2026 change&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/npm/cli/issues/8544" rel="noopener noreferrer"&gt;npm/cli issue 8544&lt;/a&gt;: allow publishing the initial version with OIDC&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/npm/cli/pull/9248" rel="noopener noreferrer"&gt;npm/cli pull request 9248&lt;/a&gt;: add permissions support to trust commands&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.blog/changelog/2025-07-31-npm-trusted-publishing-with-oidc-is-generally-available/" rel="noopener noreferrer"&gt;GitHub Changelog&lt;/a&gt;: npm trusted publishing with OIDC is generally available&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>npm</category>
      <category>node</category>
      <category>devops</category>
      <category>githubactions</category>
    </item>
    <item>
      <title>A payment QR code is just twelve lines of text</title>
      <dc:creator>Koray KÖYLÜ</dc:creator>
      <pubDate>Wed, 12 Aug 2026 04:56:12 +0000</pubDate>
      <link>https://dev.to/koraykoylu/a-payment-qr-code-is-just-twelve-lines-of-text-1bg4</link>
      <guid>https://dev.to/koraykoylu/a-payment-qr-code-is-just-twelve-lines-of-text-1bg4</guid>
      <description>&lt;p&gt;If you have paid an invoice in Europe recently, you have probably pointed your banking app at a small square and watched it fill in the payee, the IBAN and the amount by itself. I run &lt;a href="https://ibanchecker.cash" rel="noopener noreferrer"&gt;ibanchecker.cash&lt;/a&gt; and I spent the last few weeks on both sides of those squares: generating them and reading them back. The format turned out to be far simpler than most people expect, and a couple of its rules surprised me even after years around IBANs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is actually in there
&lt;/h2&gt;

&lt;p&gt;The standard is EPC069-12, published by the European Payments Council. German banks market the result as a GiroCode. There is no binary framing and no JSON. The payload is plain text, twelve elements separated by line breaks. This is the exact content of the code on the cover image:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BCD
002
1
SCT
COBADEFF
Webajans Bilisim Ltd
DE89370400440532013000
EUR149.00


Invoice 2026-014
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Line&lt;/th&gt;
&lt;th&gt;Element&lt;/th&gt;
&lt;th&gt;In this code&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Service tag&lt;/td&gt;
&lt;td&gt;always &lt;code&gt;BCD&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Version&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;002&lt;/code&gt;, which makes the BIC optional&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Character set&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;1&lt;/code&gt; means UTF-8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Identification&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;SCT&lt;/code&gt;, a SEPA Credit Transfer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;BIC&lt;/td&gt;
&lt;td&gt;&lt;code&gt;COBADEFF&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Beneficiary name&lt;/td&gt;
&lt;td&gt;who gets paid&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;IBAN&lt;/td&gt;
&lt;td&gt;the account&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;Amount&lt;/td&gt;
&lt;td&gt;currency glued to the value&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;Purpose&lt;/td&gt;
&lt;td&gt;empty here&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;Structured reference&lt;/td&gt;
&lt;td&gt;empty here&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;Unstructured reference&lt;/td&gt;
&lt;td&gt;free text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;Beneficiary to originator&lt;/td&gt;
&lt;td&gt;may be absent entirely&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few rules worth knowing before you implement any of this. The whole payload must fit in 331 bytes. The name is capped at 70 characters and the reference at 140. The amount is euro only, from 0.01 to 999,999,999.99; the standard simply has no concept of another currency. Error correction must be level M. And there is no signature anywhere: nothing in the code proves who produced it, which is worth remembering the next time a QR sticker appears on a parking meter.&lt;/p&gt;

&lt;h2&gt;
  
  
  The field we got wrong
&lt;/h2&gt;

&lt;p&gt;Line 6 is the beneficiary name. The account holder. The person or company the money goes to.&lt;/p&gt;

&lt;p&gt;The first version of our generator wrote the detected bank name there. Scan such a code and your banking app politely suggests paying "Commerzbank" instead of the person behind the account. It ran like that for about three weeks before we caught it. If you take one thing from this article: line 6 is the payee, never the bank. It is an easy mistake to make because the bank name is usually the string you have just resolved from the IBAN and it is sitting right there in a variable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rebuilding the generator
&lt;/h2&gt;

&lt;p&gt;The old tool had a Generate button. The new one updates the code live as you type, which sounds cosmetic but changes how people use it: you see the code grow as the reference gets longer, and you see the byte counter move toward the 331 limit.&lt;/p&gt;

&lt;p&gt;Exports are SVG, high resolution PNG and a vector PDF. The PDF was the fun part: a QR code is nothing but rectangles, so instead of pulling in a PDF library we walk the SVG path data and emit the rectangles into a hand-written PDF. Zero dependencies. Dropping a logo into the center automatically raises error correction to H.&lt;/p&gt;

&lt;p&gt;One design decision I want to defend explicitly: if you enter an amount in a currency other than the euro, the tool still generates the code but tells you plainly that banking apps outside SEPA may not read it. The alternative, silently producing something that looks official, felt wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading codes back
&lt;/h2&gt;

&lt;p&gt;The newer tool is the reverse: &lt;a href="https://ibanchecker.cash/tools/decode-payment-qr" rel="noopener noreferrer"&gt;drop in a picture of a payment QR&lt;/a&gt; and it lists all twelve elements with their byte counts, measured against the limits the standard sets. Decoding happens entirely in the browser with a dynamically imported jsQR; the image is never uploaded anywhere. The bytes are re-decoded with whatever character set the payload declares, so an ISO 8859 umlaut survives.&lt;/p&gt;

&lt;p&gt;The IBAN inside then runs through the same engine as the rest of the site: country, length, national structure, the ISO 13616 check digits, the national account check digit where a country defines one (25 countries so far; for Germany that means the per-bank Prüfziffer methods, for the UK the VocaLink modulus check) and a directory lookup for the bank and its BIC.&lt;/p&gt;

&lt;p&gt;Two rules were fixed before the first line of code. The image never leaves the browser. And decoded text is never rendered as a clickable link, because QR phishing works precisely by making that tap convenient.&lt;/p&gt;

&lt;p&gt;One honest limitation: Swiss QR-bills are recognized but not parsed. They follow a different standard from SIX, not the EPC one, and mapping their fields onto the EPC layout would be guessing dressed up as decoding.&lt;/p&gt;

&lt;h2&gt;
  
  
  About the cover image
&lt;/h2&gt;

&lt;p&gt;The QR on the cover is real. I generated it with the first tool and verified it by feeding the finished image back into the second one. The IBAN in it is the well-known documentation example, DE89 3704 0044 0532 0130 00, not a live account.&lt;/p&gt;

&lt;p&gt;Both tools are free and need no signup: the &lt;a href="https://ibanchecker.cash/tools/iban-qr-code" rel="noopener noreferrer"&gt;generator&lt;/a&gt; and the &lt;a href="https://ibanchecker.cash/tools/decode-payment-qr" rel="noopener noreferrer"&gt;decoder&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>payments</category>
      <category>fintech</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
    <item>
      <title>We built a free ipify alternative - here's what we learned</title>
      <dc:creator>Koray KÖYLÜ</dc:creator>
      <pubDate>Fri, 12 Jun 2026 20:23:26 +0000</pubDate>
      <link>https://dev.to/koraykoylu/we-built-a-free-ipify-alternative-heres-what-we-learned-39eb</link>
      <guid>https://dev.to/koraykoylu/we-built-a-free-ipify-alternative-heres-what-we-learned-39eb</guid>
      <description>&lt;p&gt;If you've ever needed your public IP in a script, you've probably typed this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl api.ipify.org
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;ipify is one of those quiet pieces of internet infrastructure. Launched in 2014, it answers one question — &lt;em&gt;"what is my public IP?"&lt;/em&gt; — so simply that it ended up embedded in millions of scripts and apps, serving billions of requests a month. Respect.&lt;/p&gt;

&lt;p&gt;But a while ago I noticed something while browsing its &lt;a href="https://github.com/rdegges/ipify-api" rel="noopener noreferrer"&gt;GitHub repo&lt;/a&gt;: the last code change was &lt;strong&gt;January 2018&lt;/strong&gt;. Community pull requests — CORS headers, Docker support, even one-line README fixes — have been sitting unanswered for years. The hosted service still runs (it's commercially operated by WhoisXML API), but the open-source project that made developers trust it is effectively frozen. And geolocation was never part of the free deal — it's a separate paid product.&lt;/p&gt;

&lt;p&gt;That gap looked like an invitation. We run &lt;a href="https://whatsmy.fyi" rel="noopener noreferrer"&gt;whatsmy.fyi&lt;/a&gt;, an IP and network info tool, and we decided to build the thing we wished existed. Here's what we shipped and — more usefully — what we learned along the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we built
&lt;/h2&gt;

&lt;p&gt;A keyless endpoint that's deliberately compatible with ipify's response format:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Plain text&lt;/span&gt;
curl https://whatsmy.fyi/ip
&lt;span class="c"&gt;# → 203.0.113.42&lt;/span&gt;

&lt;span class="c"&gt;# JSON — identical shape to api.ipify.org&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://whatsmy.fyi/ip?format=json"&lt;/span&gt;
&lt;span class="c"&gt;# → {"ip":"203.0.113.42"}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Swapping it into existing code is a one-URL change. No key, no signup, CORS enabled, IPv4 + IPv6, nothing logged.&lt;/p&gt;

&lt;p&gt;When you need more than the bare IP, a free API key (10,000 requests/day) returns city, country, coordinates, timezone, ASN, and ISP — the data ipify sells separately. There's also a &lt;a href="https://github.com/koraykoylu/whatsmyfyi-sdk" rel="noopener noreferrer"&gt;TypeScript SDK&lt;/a&gt; with a keyless mode:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;WhatsMyFyi&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@whatsmyfyi/sdk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WhatsMyFyi&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;           &lt;span class="c1"&gt;// no key needed&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;address&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;      &lt;span class="c1"&gt;// "203.0.113.42"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Lesson 1: friction decides everything in this category
&lt;/h2&gt;

&lt;p&gt;Our API originally required a key for everything. Reasonable, right? Usage tracking, rate limits, dashboards.&lt;/p&gt;

&lt;p&gt;Wrong — at least for this use case. The entire reason &lt;code&gt;curl api.ipify.org&lt;/code&gt; won the internet is that it works &lt;em&gt;before you've decided to care&lt;/em&gt;. A developer comparing IP APIs closes the tab the moment they see "sign up first." We realized the keyless endpoint wasn't a nice-to-have; it was the price of admission. The authenticated API now earns its keep as the &lt;em&gt;upgrade path&lt;/em&gt;, not the front door.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 2: the edge changed the economics
&lt;/h2&gt;

&lt;p&gt;ipify's era required choices: serving geolocation means licensing a GeoIP database, updating it, and eating lookup latency. Charging for geo made sense in 2014.&lt;/p&gt;

&lt;p&gt;Running on Cloudflare Workers changed that math for us. Every request already arrives with geolocation, ASN, and timezone attached (the &lt;code&gt;request.cf&lt;/code&gt; object) — data from the network itself, with no external database call. Serving "rich" IP data now costs us roughly the same as serving the bare IP. That's not cleverness on our part; it's a structural shift that makes the old free-IP/paid-geo split feel dated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 3: honesty is a feature you can ship
&lt;/h2&gt;

&lt;p&gt;We published a &lt;a href="https://whatsmy.fyi/compare" rel="noopener noreferrer"&gt;comparison page&lt;/a&gt; covering ipify, icanhazip, ip-api.com, ipapi.co, and ipinfo — and we verified every claim by calling each API ourselves (ip-api.com really does reject HTTPS on the free tier; ipapi.co really did 429 our first request). The page also lists the places where competitors beat us, and recommends icanhazip for shell scripts and ipinfo for enterprise needs, because that's the truth.&lt;/p&gt;

&lt;p&gt;This felt risky to write. But developers can smell a rigged comparison table from a mile away; an honest one is rare enough to be memorable — and it gives every claim on the page a "verify it yourself" quality that no marketing copy can match.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 4: maintenance is the moat
&lt;/h2&gt;

&lt;p&gt;Here's the uncomfortable part: nothing we built is technically hard. A keyless IP endpoint is a weekend project. What ipify's story actually demonstrates is that the hard part is &lt;em&gt;still being there&lt;/em&gt; — answering issues, merging PRs, shipping releases — years after the launch excitement fades.&lt;/p&gt;

&lt;p&gt;So we wrote the only durable differentiator we could into our &lt;a href="https://github.com/koraykoylu/whatsmyfyi-sdk/blob/main/CONTRIBUTING.md" rel="noopener noreferrer"&gt;CONTRIBUTING.md&lt;/a&gt;: every issue and PR gets a human response within a few days. If we ever stop honoring that, someone should build the whatsmy.fyi alternative — and they'd be right to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Keyless endpoint: &lt;code&gt;curl https://whatsmy.fyi/ip&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://whatsmy.fyi/docs" rel="noopener noreferrer"&gt;API docs&lt;/a&gt; · &lt;a href="https://whatsmy.fyi/openapi.json" rel="noopener noreferrer"&gt;OpenAPI spec&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/koraykoylu/whatsmyfyi-sdk" rel="noopener noreferrer"&gt;SDK on GitHub&lt;/a&gt; · &lt;a href="https://www.npmjs.com/package/@whatsmyfyi/sdk" rel="noopener noreferrer"&gt;npm&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="https://whatsmy.fyi/compare" rel="noopener noreferrer"&gt;The full comparison&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Questions, criticism, and "actually you got X wrong" comments are genuinely welcome — that's how the comparison page stays honest.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>typescript</category>
      <category>api</category>
      <category>opensource</category>
    </item>
    <item>
      <title>IP geolocation with zero external APIs, the Cloudflare Workers cf object</title>
      <dc:creator>Koray KÖYLÜ</dc:creator>
      <pubDate>Wed, 10 Jun 2026 12:49:39 +0000</pubDate>
      <link>https://dev.to/koraykoylu/ip-geolocation-with-zero-external-apis-the-cloudflare-workers-cf-object-16e</link>
      <guid>https://dev.to/koraykoylu/ip-geolocation-with-zero-external-apis-the-cloudflare-workers-cf-object-16e</guid>
      <description>&lt;p&gt;When I built &lt;a href="https://whatsmy.fyi" rel="noopener noreferrer"&gt;whatsmy.fyi&lt;/a&gt;, I assumed I'd need a geolocation provider: MaxMind, ipinfo, ip-api, pick your poison. They all mean the same thing: an external dependency, an API key, a quota, added latency, and someone else's server seeing your users' IPs.&lt;/p&gt;

&lt;p&gt;Then I found out Cloudflare Workers makes the whole category unnecessary.&lt;/p&gt;

&lt;h2&gt;
  
  
  The &lt;code&gt;cf&lt;/code&gt; object
&lt;/h2&gt;

&lt;p&gt;Every request that hits a Cloudflare Worker carries a &lt;code&gt;request.cf&lt;/code&gt; object, populated at the edge before your code even runs. No lookup, no latency, no key. Here's what's inside:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;asn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;34984&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                    &lt;span class="c1"&gt;// ISP's autonomous system number&lt;/span&gt;
  &lt;span class="nx"&gt;asOrganization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Superonline&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// ISP name&lt;/span&gt;
  &lt;span class="nx"&gt;city&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Istanbul&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;region&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Istanbul&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;country&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;TR&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;continent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;AS&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;isEUCountry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// "1" if EU, undefined otherwise&lt;/span&gt;
  &lt;span class="nx"&gt;latitude&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;41.01380&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;// string, not number!&lt;/span&gt;
  &lt;span class="nx"&gt;longitude&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;28.94970&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;postalCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;34000&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Europe/Istanbul&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;colo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;IST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                   &lt;span class="c1"&gt;// which CF datacenter handled this&lt;/span&gt;
  &lt;span class="nx"&gt;clientTcpRtt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;              &lt;span class="c1"&gt;// user's RTT to the edge, in ms&lt;/span&gt;
  &lt;span class="nx"&gt;httpProtocol&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;HTTP/3&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;tlsVersion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;TLSv1.3&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;tlsCipher&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;AEAD-AES128-GCM-SHA256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last group surprised me most: you get the user's &lt;strong&gt;HTTP protocol, TLS version, and actual TCP round-trip time&lt;/strong&gt; for free. Try getting that from a geo API.&lt;/p&gt;

&lt;h2&gt;
  
  
  A complete IP endpoint in ~30 lines
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cf&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;CF-Connecting-IP&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;city&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;city&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;country&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;country&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;isp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;asOrganization&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;asn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;asn&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timezone&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;latitude&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;parseFloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;latitude&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;lng&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;longitude&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;parseFloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;longitude&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;httpProtocol&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;tls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tlsVersion&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;rttMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientTcpRtt&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire backend. No database, no GeoIP file to update monthly, no vendor.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotchas (learned the hard way)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Coordinates are strings.&lt;/strong&gt; &lt;code&gt;latitude: "41.01380"&lt;/code&gt; — parse them or your JSON consumers will suffer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Every field can be missing.&lt;/strong&gt; Tor exits, some corporate proxies, and brand-new IP ranges come through with sparse data. Null-check everything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;city&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;cf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;city&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;cf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;city&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. &lt;code&gt;isEUCountry&lt;/code&gt; is the string &lt;code&gt;"1"&lt;/code&gt; or undefined.&lt;/strong&gt; Not a boolean. You've been warned.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Local dev gives you an empty object.&lt;/strong&gt; &lt;code&gt;wrangler dev&lt;/code&gt; (and Next.js dev servers behind adapters) won't populate most fields. Guard for it, and test geo logic in preview deployments.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. City-level accuracy is "usually right, sometimes hilarious."&lt;/strong&gt; Same as every IP geolocation source, this data comes from the same underlying registries. Country is reliable; city is best-effort. Don't build anything that &lt;em&gt;requires&lt;/em&gt; city precision.&lt;/p&gt;

&lt;h2&gt;
  
  
  The privacy angle
&lt;/h2&gt;

&lt;p&gt;This architecture has a property I didn't fully appreciate until I wrote the privacy policy: &lt;strong&gt;the user's IP never leaves the request path.&lt;/strong&gt; There's no third-party geo provider receiving your traffic logs as a side effect. Combined with not writing logs yourself, you can honestly say: nothing is stored, nothing is shared. That's the whole privacy section.&lt;/p&gt;

&lt;p&gt;For &lt;a href="https://whatsmy.fyi" rel="noopener noreferrer"&gt;whatsmy.fyi&lt;/a&gt; this is the entire data pipeline: the site, the &lt;a href="https://whatsmy.fyi/docs" rel="noopener noreferrer"&gt;free API&lt;/a&gt;, all of it runs on &lt;code&gt;request.cf&lt;/code&gt; and nothing else. Sub-50ms TTFB, zero external calls, zero logs.&lt;/p&gt;

&lt;p&gt;If you're on Workers and calling a geo API today, check &lt;code&gt;request.cf&lt;/code&gt; first. The data was already there before you asked.&lt;/p&gt;

</description>
      <category>cloudflarechallenge</category>
      <category>serverless</category>
      <category>webdev</category>
      <category>privacy</category>
    </item>
    <item>
      <title>I built a "what is my IP" site because I was tired of the ugly ones</title>
      <dc:creator>Koray KÖYLÜ</dc:creator>
      <pubDate>Thu, 28 May 2026 15:39:14 +0000</pubDate>
      <link>https://dev.to/koraykoylu/i-built-a-what-is-my-ip-site-because-i-was-tired-of-the-ugly-ones-263j</link>
      <guid>https://dev.to/koraykoylu/i-built-a-what-is-my-ip-site-because-i-was-tired-of-the-ugly-ones-263j</guid>
      <description>&lt;p&gt;I use "what is my IP" sites maybe once a month. Every time I end up on something covered in ads, calling three different tracking APIs, and showing me results I don't fully understand. So I spent a weekend building whatsmy.fyi.&lt;/p&gt;

&lt;p&gt;The thing I didn't expect: you don't need an IP geolocation  API at all if you're on Cloudflare Workers. Every request  comes with a &lt;code&gt;cf&lt;/code&gt; object that already has your city, country,  ISP, TLS version, HTTP protocol, and RTT. Free. Zero latency.&lt;/p&gt;

&lt;p&gt;The part I enjoyed most was the WebRTC leak test. It checks  whether your browser is exposing your real IP through  RTCPeerConnection even when you're on a VPN. I ran it on  my own setup. It was leaking.&lt;/p&gt;

&lt;p&gt;Zero logs. Zero storage. Just your data, shown to you.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://whatsmy.fyi" rel="noopener noreferrer"&gt;https://whatsmy.fyi&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>showdev</category>
      <category>cloudflare</category>
      <category>privacy</category>
    </item>
  </channel>
</rss>
