Every one-click reverse image search extension works the same way, and it is not the way you’d guess. It doesn’t upload anything. It builds a URL:
const ENGINES = {
google: (u: string) => `https://lens.google.com/uploadbyurl?url=${encodeURIComponent(u)}`,
bing: (u: string) => `https://www.bing.com/images/searchbyimage?cbir=sbi&imgurl=${encodeURIComponent(u)}`,
yandex: (u: string) => `https://yandex.com/images/search?rpt=imageview&url=${encodeURIComponent(u)}`,
tineye: (u: string) => `https://tineye.com/search?url=${encodeURIComponent(u)}`,
};
Right-click an image, read info.srcUrl, open four tabs. The engine fetches the image itself, server-side. It’s twenty lines and it’s genuinely elegant — the extension never touches image bytes, never needs a backend, never sees your data.
It also fails on a large fraction of the web, and it fails silently: you get a results page that says “no matches,” which is indistinguishable from a real no-match.
I hit this building Reverse Image Search Anywhere, and digging out took four distinct fallback tiers. This is what’s under each one.
Problem zero: you don’t even get a URL
Before the redirect can fail, something more basic breaks.
chrome.contextMenus only populates info.srcUrl when the right-click actually landed on an element. Instagram, Pinterest, and Facebook all lay a transparent element over each photo — an interaction shield for their own gesture handling. Your right-click hits the shield. Chrome reports a page context with no image at all, and your extension’s image menu item doesn’t even appear.
The fix is to stop asking the browser and look yourself. A content script listens for contextmenu in the capture phase (so the page can’t stopPropagation() you out of it) and walks the element stack under the cursor:
document.addEventListener('contextmenu', (e) => {
lastRightClickSrc = pickImageUrl(document.elementsFromPoint(e.clientX, e.clientY));
}, true); // ← capture
export function pickImageUrl(elements: Element[]): string | null {
for (const el of elements) {
if (el.tagName === 'IMG') {
const img = el as HTMLImageElement;
// currentSrc, not src — with srcset, `src` is the fallback the browser
// may never have loaded.
const src = img.currentSrc || img.src;
if (src) return src;
}
}
for (const el of elements) {
const url = backgroundImageUrl(el); // computed style, first url(...)
if (url) return url;
}
return null;
}
document.elementsFromPoint() returns the whole stack, topmost first, so the shield is element 0 and the real is usually element 1 or 2. Two details earn their keep: currentSrc over src (with srcset, src is a fallback the browser may never have fetched), and the CSS background-image pass, which catches the surprising number of sites that render photos as div backgrounds.
Now the background worker can ask the content script “what was under the cursor?” and get an answer where Chrome had none.
The URL you got may still be useless
Having a URL isn’t the same as having one an engine can fetch. It’s useless when it’s:
-
blob:ordata:— page-scoped.blob:https://example.com/abc-123is meaningless outside that document. -
hotlink-protected — the CDN checks
Refererand serves a 403 to Google’s fetcher. - cookie-gated — a signed URL on an authenticated CDN. Google isn’t logged in.
-
drawn on a
— there is no URL. There was never a URL.
So for those cases you need the actual bytes. Which is where it gets interesting, because no single mechanism can grab them all, and the reasons are unrelated to each other.
Four tiers, each covering the one above it
| Tier | Mechanism | Reaches | Blind to |
|---|---|---|---|
| 0 | It’s already a data: URL |
inline images | everything else |
| 1 |
fetch in the background worker |
most CDNs, hotlink-protected |
blob:, cookie-gated |
| 2 |
fetch in the content script |
blob:, cookie-gated, private |
cross-origin without CORS |
| 3 | Rasterize the to a canvas |
same-origin, CORS-enabled, canvas | tainted canvases |
Tier 1 — the background worker. This is the one people miss. A Manifest V3 service worker holding host permissions makes fetches that are not subject to page CORS. The extension’s origin is privileged; no preflight, no opaque response. That single property gets you the bytes of most CDN images that the redirect method can’t hand to an engine:
export async function grabImageBlobFromBackground(srcUrl: string): Promise<Blob | null> {
// Page-scoped schemes are unreachable from here, by construction.
if (srcUrl.startsWith('blob:') || srcUrl.startsWith('data:')) return null;
try {
const res = await fetch(srcUrl, { credentials: 'omit' });
if (!res.ok) return null;
const blob = await res.blob();
if (blob.size === 0 || !blob.type.startsWith('image/')) return null;
return blob;
} catch {
return null;
}
}
Note credentials: 'omit'. The worker has no useful session for a third-party site anyway, and sending what it does have to arbitrary origins is not a thing you want to do by default.
Tier 2 — inside the page. Tier 1 returns null for exactly two situations, and the content script is the answer to both. A blob: URL only resolves in the document that created it. And an in-page fetch with credentials: 'include' carries the site’s session cookies and a same-origin Referer, so it reaches cookie-gated and private images — the ones on an authenticated CDN that no external fetcher can touch.
Same request, different execution context, completely different reachability. That’s the whole trick.
Tier 3 — the canvas. If even an in-page fetch fails (cross-origin, no CORS headers), the image is still rendered on screen. Those pixels exist:
function elementToDataUrl(srcUrl: string): string {
const img = Array.from(document.images).find(
(el) => el.currentSrc === srcUrl || el.src === srcUrl,
);
if (!img || !img.complete || img.naturalWidth === 0) throw new Error('image element not found');
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
canvas.getContext('2d')!.drawImage(img, 0, 0);
// Throws SecurityError if the canvas is tainted (cross-origin, no CORS).
return canvas.toDataURL('image/png');
}
And here the ladder honestly ends. Drawing a cross-origin image without CORS headers taints the canvas, and toDataURL() throws SecurityError. That’s not a bug to route around — it’s the same-origin policy doing its job. The extension shows a toast and stops. A tool that claims to work everywhere is lying about this tier.
The chain, cheapest first:
if (srcUrl.startsWith('data:')) return searchImageByUpload(srcUrl, ids);
const blob = await grabImageBlobFromBackground(srcUrl);
if (blob) return searchImageBlob(blob, ids);
// blob: / cookie-gated — only the page can reach it
await sendToTab(tabId, { type: MSG.GRAB_IMAGE, srcUrl, engineIds: ids });
Bytes in hand. The engines still want a URL.
Google Lens has no public “here are some bytes” endpoint. So the bytes need to briefly become a URL: upload to private S3, hand each engine a short-lived presigned GET.
The presign is minted by a small Rails endpoint, and it contains the single most useful thing I learned on this project:
A presigned PUT cannot cap upload size. S3 does not enforce a signed
Content-Length.
I verified this empirically after assuming otherwise. If you hand out a presigned PUT, whoever holds that URL can write an object of any size, up to S3’s limits, and you pay for it. A presigned POST with content_length_range is different — the constraint is in the signed policy document, so S3 itself rejects an oversized body with EntityTooLarge:
class S3Presigner
PUT_TTL = 300 # 5 min to complete the upload
GET_TTL = 900 # 15 min for the engines to fetch
MAX_BYTES = 15 * 1024 * 1024 # enforced by S3, not by us
def self.presign(content_type:)
key = "u/#{SecureRandom.uuid}"
post = Aws::S3::PresignedPost.new(
client.config.credentials, REGION, BUCKET,
key: key,
content_type: content_type,
content_length_range: 1..MAX_BYTES,
signature_expiration: Time.now + PUT_TTL
)
{ upload: { url: post.url, fields: post.fields },
get_url: object(key).presigned_url(:get, expires_in: GET_TTL),
max_bytes: MAX_BYTES }
end
end
Three more things that cost me time:
Field order in the multipart body is load-bearing. The file field must come last, after every signed policy field. S3 stops parsing at file — anything after it is ignored, and you get a signature error that says nothing about ordering:
const form = new FormData();
for (const [k, v] of Object.entries(upload.fields)) form.append(k, v);
form.append('file', blob, 'image'); // ← must be last
The upload has to run in the worker too. A content script POSTing to S3 hits page CORS. Same reasoning as tier 1 — the privileged context is the one that can talk to arbitrary origins.
Rate-limit the presign endpoint. No bytes pass through the server, so it’s cheap and easy to forget — but every success hands out an S3 write grant. Rails 8 ships this built in:
rate_limit to: 20, within: 1.minute,
with: -> { render json: { error: "rate limited" }, status: :too_many_requests }
The bucket has Block Public Access on and never serves anything publicly. The GET URL is unguessable and expires in 15 minutes; a one-day lifecycle rule sweeps objects regardless of what the app does.
The fallback I deliberately didn’t build
When the whole ladder fails, there’s an obvious-looking last resort: give up on the bytes and just open the raw srcUrl in the engines. Something is better than nothing.
It isn’t. Consider the case that lands there most often — a cookie-gated image on an authenticated CDN, say a generated image in a ChatGPT conversation. That URL is signed and tied to your session. Handing it to four search engines doesn’t work, because they can’t fetch it either. What it does do is leak an authenticated link to four third parties.
So the extension doesn’t. This path is reached only when the user explicitly chose “search by upload,” or on an overlay site where they implicitly chose not to hand over the image’s URL. Quietly falling back to the exact behavior they opted out of would be a bad trade dressed up as a graceful degradation.
Failing loudly with a toast is the right answer. Not every fallback is worth having.
The part that matters most
All of the above — the four tiers, the S3 bucket, the presign endpoint, the rate limiter — is the slow path. It runs only when the fast path can’t.
On a normal site with a plain, fetchable image URL, the extension builds four engine URLs and opens four tabs. No bytes are read, no server is contacted, nothing is uploaded. Same twenty elegant lines it started with.
That ordering wasn’t an optimization, it was the design constraint. The expensive path is also the privacy-sensitive one, and building it as an explicit fallback rather than the default means the common case stays fully client-side — and I can say exactly which right-click sends an image anywhere, because it’s the only branch that can.
Ladders are worth building. Just make sure you’re on the bottom rung as rarely as possible.
The extension is Reverse Image Search Anywhere — free, in the Chrome Web Store, and installable on Edge, Brave, Opera and other Chromium browsers from there. A Firefox build is in AMO review. Built with WXT and TypeScript, with a small Rails backend. If you’ve found a site that defeats all four tiers, I’d like to hear about it.