How to Bypass CAPTCHA With Selenium in Ruby: A Realistic Guide

26 July 2026 | 22 min read

The most reliable way to bypass CAPTCHA with Selenium in Ruby is to avoid triggering it in the first place, not to solve it after it appears. Selenium exposes automation signals such as navigator.webdriver and often runs from flagged datacenter IPs, which is what brings up the CAPTCHA.

This guide shows how to harden a Ruby Selenium script, which modern CAPTCHA systems you cannot reliably solve, when a CAPTCHA-solving service may help, and the legitimate ways to handle CAPTCHA in your own automated tests.

How to Bypass CAPTCHA With Selenium in Ruby: A Realistic Guide

Key takeaways

  • The most reliable way to bypass CAPTCHA checks is to avoid triggering them in the first place, not to fight a challenge after it appears.
  • Selenium can be flagged because it exposes automation signals such as navigator.webdriver and often runs through low-reputation datacenter IPs.
  • Behavioral systems such as reCAPTCHA v3 and Cloudflare Turnstile do not always present a puzzle; they score the browser, network, and user behavior instead.
  • Ruby has no widely adopted, actively maintained equivalent of Python’s undetected-chromedriver, so Selenium hardening has to be done manually with Chrome::Options, and each setting only hides one signal.
  • CAPTCHA bypassing may violate a site’s Terms of Service. Only automate public data you are allowed to access, and never use these techniques on login, payment, or other sensitive flows.

Can you bypass a CAPTCHA with Selenium in Ruby?

Yes, but mostly by reducing the chance that a CAPTCHA appears. A Selenium Ruby script can hide some automation signals and reuse a more natural browser session, but it cannot reliably bypass modern behavioral systems such as reCAPTCHA v3 or Cloudflare Turnstile. These systems evaluate browser signals and user behavior rather than waiting for one fixed puzzle to solve.

The “I’m not a robot” checkbox is reCAPTCHA v2. Completing it may produce an image challenge, and a successful check returns a response token that the website verifies. hCaptcha follows a similar token-based flow. Third-party solving services can sometimes provide these tokens, but they add cost and delay, can fail, and do not make the Selenium session itself look trustworthy.

Sometimes you can avoid triggering one, but there is no universal way to bypass reCAPTCHA with Selenium. Once reCAPTCHA v3 or Turnstile distrusts the browser, changing one Chrome option or clicking a checkbox is rarely enough.

Is bypassing CAPTCHA illegal?

Not automatically, but it can still get you into trouble. I am not a lawyer, and this is not legal advice: whether CAPTCHA bypassing is legal depends on what you access, how you access it, the website’s Terms of Service, and the laws in your jurisdiction. In the United States, unauthorized access may also raise questions under the Computer Fraud and Abuse Act.

The sensible rule is pretty simple: only collect public data you are allowed to access. Respect the site’s Terms and robots.txt, keep request rates reasonable, and do not bypass CAPTCHAs protecting logins, account creation, payments, ticket sales, or other restricted actions. Note, however, that robots.txt communicates crawl preferences; it is not access authorization or permission to ignore the site’s Terms.

When testing your own application, there is no reason to fight the real CAPTCHA. Disable it in the test environment or use Google’s official reCAPTCHA test keys. For reCAPTCHA v2, those keys always pass verification; for v3, Google recommends creating a separate testing key.

When access is needed to someone else’s protected site, the boring answer is also the safest one: ask the owner for permission or an API.

Why Selenium gets flagged

Selenium triggers CAPTCHA checks because the browser does not look or behave like a normal user session. Detection scripts can read automation markers, compare the browser fingerprint with the network it comes from, and notice repetitive behavior. Fixing those signals will not make Selenium invisible, but it can reduce how often a CAPTCHA appears.

The most obvious giveaway is navigator.webdriver. WebDriver-controlled browsers normally expose it as true, giving the page a direct signal that automation is running. ChromeDriver also launches Chrome with automation-specific switches and defaults that can leave more clues behind.

The IP matters just as much as the browser. A perfectly configured Selenium session can still get blocked when it sends hundreds of requests through one reused datacenter IP with a bad reputation.

Behavior is another giveaway. Bot sessions often open a page, click the same element immediately, follow the same route, and disappear seconds later. Real users pause, scroll, revisit pages, and do things in a much less tidy order.

Finally, the fingerprint has to make sense as a whole. A desktop user agent paired with a tiny headless viewport, the wrong timezone, unusual language settings, or an IP on another continent looks suspicious even when each individual value seems valid.

That is the real answer to how to avoid bot detection with Selenium: make the browser, IP, and behavior agree with each other. Do that before reaching for a CAPTCHA-solving service. For more practical checks, see this guide to web scraping without getting blocked.

How to harden a Selenium script in Ruby

There is no magic bypass_captcha: true option in Selenium. The practical approach is to remove obvious automation signals one by one, keep the browser fingerprint internally consistent, and check each change against a detection page before touching the real target.

The examples below use pure Ruby—no Python wrapper hiding behind the curtain—and work with selenium-webdriver 4.46.0.

Use Selenium only when the page needs a browser

Before hardening Selenium, check whether you need it at all. If the data is already present in the initial HTML or an accessible JSON endpoint, a regular Ruby HTTP client with Nokogiri is faster, cheaper, and easier to maintain. Save Selenium for pages that genuinely require JavaScript execution or browser interaction. See web scraping with Ruby for the lighter HTTP-based approach.

What you need

You need Ruby 3.3 or newer, Google Chrome, and the official selenium-webdriver gem.

Install it with:

gem install selenium-webdriver

That is enough. Selenium Manager detects the installed Chrome version and downloads the matching ChromeDriver automatically when needed, so there is no reason to hunt for a driver binary and place it on PATH manually.

Step 1 — Run a baseline script and watch it get flagged

Start with a plain Selenium Ruby script and no stealth tweaks. It opens Chrome, loads the SannySoft bot-detection page, and checks the most obvious automation signal.

Create baseline.rb:

require "selenium-webdriver"

driver = Selenium::WebDriver.for(:chrome)

begin
  driver.navigate.to("https://bot.sannysoft.com/")

  webdriver_flag = driver.execute_script(
    "return navigator.webdriver"
  )

  puts "navigator.webdriver: #{webdriver_flag.inspect}"

  # Keep the browser open briefly so you can inspect the results.
  sleep 10
ensure
  driver.quit
end

Run it:

ruby baseline.rb

The terminal should print:

navigator.webdriver: true

And there is our first problem. navigator.webdriver tells page scripts that the browser is controlled through WebDriver. It is not the only signal modern anti-bot systems inspect, but it is one of the easiest checks to make—and the default Selenium session fails it immediately.

The SannySoft page will probably show several other failed checks too. That is useful: this baseline gives us something concrete to compare against as we harden the browser in the next steps.

Passing a public bot-detection page does not prove that a protected website will accept the session. These pages are useful for comparing individual signals, but production anti-bot systems also use server-side history, IP reputation, and site-specific behavior.

Step 2 — Hide the WebDriver flag

The baseline browser exposes navigator.webdriver: true, so let us remove that obvious signal first.

Add --disable-blink-features=AutomationControlled through Chrome::Options:

require "selenium-webdriver"

options = Selenium::WebDriver::Chrome::Options.new

options.add_argument(
  "--disable-blink-features=AutomationControlled"
)

driver = Selenium::WebDriver.for(
  :chrome,
  options: options
)

begin
  driver.navigate.to("https://bot.sannysoft.com/")

  webdriver_flag = driver.execute_script(
    "return navigator.webdriver"
  )

  puts "navigator.webdriver: #{webdriver_flag.inspect}"

  sleep 10
ensure
  driver.quit
end

Run the script again:

ruby baseline.rb

The result should now be:

navigator.webdriver: false

That is a real improvement: page scripts no longer get an immediate true value from navigator.webdriver.

Still, this is only one signal. The browser is still controlled by Selenium, and detection systems can inspect its user agent, viewport, languages, WebGL output, IP reputation, and behavior.

You may see tutorials recommending excludeSwitches: ["enable-automation"] or useAutomationExtension: false. I would not add them blindly. In my Selenium 4.46.0 test on Windows, excluding enable-automation caused Chrome to exit before the WebDriver session was created. The Blink flag above worked without breaking the browser, so that is the version used here.

Retest headless mode separately

These examples use visible Chrome. If you later add --headless=new, run the fingerprint checks again: headless execution can change observable window and screen values, so do not assume that a headed configuration behaves identically.

Step 3 — Check that the fingerprint is consistent

Hiding one automation flag is not enough. The browser fingerprint also needs to make sense as a whole.

Do not start by hard-coding a different user agent, language, timezone, and platform. Selenium launches the installed Chrome browser, so many default values are already correct for the real browser and operating system. Careless spoofing can introduce contradictions that are easier to detect than the original defaults.

Instead, set a normal window size and inspect the values the website can see:

require "selenium-webdriver"

options = Selenium::WebDriver::Chrome::Options.new

options.add_argument(
  "--disable-blink-features=AutomationControlled"
)
options.add_argument("--window-size=1440,900")

driver = Selenium::WebDriver.for(
  :chrome,
  options: options
)

begin
  driver.navigate.to("https://bot.sannysoft.com/")

  fingerprint = driver.execute_script(<<~JAVASCRIPT)
    return {
      webdriver: navigator.webdriver,
      webdriverType: typeof navigator.webdriver,
      userAgent: navigator.userAgent,
      platform: navigator.platform,
      language: navigator.language,
      languages: navigator.languages,
      timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
      viewport: `${window.innerWidth}x${window.innerHeight}`,
      screen: `${window.screen.width}x${window.screen.height}`
    };
  JAVASCRIPT

  fingerprint.each do |name, value|
    puts "#{name}: #{value.inspect}"
  end

  sleep 10
ensure
  driver.quit
end

Run the script:

ruby baseline.rb

The exact output depends on your system, but the WebDriver values should look like this after Step 2:

webdriver: false
webdriverType: "boolean"

Do not spoof everything blindly

Python has tools such as undetected-chromedriver and its official successor, nodriver, that handle multiple browser signals together. Ruby has no equally mature drop-in equivalent, so inspect the fingerprint for contradictions first and change only the values you can verify. Randomly overriding every property can make the browser easier to detect, not harder.

That is preferable to replacing the standard Boolean property with an unusual undefined value. A normal browser exposes navigator.webdriver; it simply returns false when WebDriver automation is not active.

Now inspect the rest of the output for contradictions:

  • the user agent and platform should match the operating system running Chrome;
  • the language list should make sense for the browser configuration;
  • the timezone should not conflict badly with the proxy location;
  • the viewport should resemble a normal desktop browser window;
  • related requests should keep the same general fingerprint throughout one session.

For example, a Windows user agent combined with a Linux platform is suspicious. An IP in Germany paired with an unexpected Asian timezone and language may also attract attention, although travelers and VPN users mean that no single mismatch proves automation.

There is usually no need to override the user agent manually. Chrome already reports one that matches its installed version and operating system. A hard-coded user agent eventually becomes outdated and may disagree with browser headers or other JavaScript properties.

The SannySoft page is useful for comparing individual signals before and after a change, but passing its checks does not prove that a protected website will accept the session. Production anti-bot systems can also use IP reputation, request history, cookies, network characteristics, and site-specific behavior that a public detection page cannot reproduce.

Step 4 — Use an IP with a better reputation

A consistent browser fingerprint will not help much if every request still comes from an IP address that the website already distrusts.

This is especially common when Selenium runs on a VPS or cloud server. Other users may have scraped through the same datacenter range before you, and repeated requests from one static address can quickly trigger another CAPTCHA.

Chrome can route its traffic through a proxy with the --proxy-server argument:

require "selenium-webdriver"

proxy = ENV.fetch("SELENIUM_PROXY")
# Example: http://proxy.example.com:8080

options = Selenium::WebDriver::Chrome::Options.new

options.add_argument(
  "--disable-blink-features=AutomationControlled"
)
options.add_argument("--window-size=1440,900")
options.add_argument("--proxy-server=#{proxy}")

driver = Selenium::WebDriver.for(
  :chrome,
  options: options
)

begin
  driver.navigate.to("https://httpbin.org/ip")

  wait = Selenium::WebDriver::Wait.new(timeout: 10)

  body = wait.until do
    element = driver.find_element(tag_name: "body")
    element if element.displayed? && !element.text.empty?
  end

  puts body.text

  sleep 10
ensure
  driver.quit
end

Save the script as proxy_test.rb, then set the proxy address before running it.

On Linux or macOS:

export SELENIUM_PROXY="http://proxy.example.com:8080"
ruby proxy_test.rb

In PowerShell:

$env:SELENIUM_PROXY = "http://proxy.example.com:8080"
ruby .\proxy_test.rb

The IP returned by httpbin.org should belong to the proxy rather than your normal connection.

For stricter websites, residential or mobile IPs often have a better starting reputation than public cloud ranges, although abused proxy pools can also be detected. Do not rotate the IP on every request without a reason. Keeping one address for a logical browsing session usually looks more consistent than changing locations between page loads.

Authenticated proxy limitation

This setup works best with proxies that do not require a username and password. Chrome does not use credentials embedded in manual proxy settings such as --proxy-server=http://user:password@host:port. Authenticated HTTP proxies may require a Chrome extension, a local proxy bridge, or another authentication mechanism.

A proxy changes the network address, not the rest of the session. It does not repair browser fingerprint inconsistencies, unrealistic navigation, excessive request rates, missing cookies, or an account that has already been blocked. Treat IP reputation as one layer of CAPTCHA avoidance, not as a universal bypass.

Step 5 — Slow down and reuse the same session

Scripts that perform several actions within milliseconds create an obvious automation pattern. Use explicit waits for page readiness, add small pauses between meaningful actions, and limit scrolling instead of racing through the entire page instantly.

Start with two small helpers:

def pause_between_actions(min: 0.8, max: 2.2)
  sleep(rand(min..max))
end

def scroll_page(driver, max_steps: 8)
  max_steps.times do
    distance = rand(250..600)

    driver.execute_script(
      "window.scrollBy({ top: arguments[0], behavior: 'smooth' })",
      distance
    )

    pause_between_actions(min: 0.5, max: 1.4)

    at_bottom = driver.execute_script(<<~JAVASCRIPT)
      return window.innerHeight + window.scrollY >=
        document.documentElement.scrollHeight - 10;
    JAVASCRIPT

    break if at_bottom
  end
end

The max_steps limit matters. On an infinite-scroll page, the document height may keep increasing as new content loads, so a loop that runs until it reaches the bottom may never finish.

Use Selenium’s explicit waits before interacting with an element:

driver.navigate.to("https://example.com")

wait = Selenium::WebDriver::Wait.new(timeout: 10)

heading = wait.until do
  element = driver.find_element(css: "h1")
  element if element.displayed?
end

pause_between_actions(min: 1.0, max: 2.5)
puts heading.text

scroll_page(driver)
pause_between_actions

Explicit waits and randomized pauses solve different problems. The wait pauses until a specific element is ready; the additional delay prevents every action from happening immediately after the technical condition becomes true.

The goal is not to generate random noise or pretend that a few sleep calls perfectly imitate a person. It is simply to avoid fixed delays, instant scrolling, and bursts of actions that no normal browsing session would produce.

You should also reuse cookies and browser storage across related runs. A browser that appears as a completely new visitor every few minutes can look suspicious, especially when its IP address changes at the same time.

The simplest approach is to give Chrome a persistent user data directory. Here is a complete example that includes the helpers:

require "fileutils"
require "selenium-webdriver"

def pause_between_actions(min: 0.8, max: 2.2)
  sleep(rand(min..max))
end

def scroll_page(driver, max_steps: 8)
  max_steps.times do
    distance = rand(250..600)

    driver.execute_script(
      "window.scrollBy({ top: arguments[0], behavior: 'smooth' })",
      distance
    )

    pause_between_actions(min: 0.5, max: 1.4)

    at_bottom = driver.execute_script(<<~JAVASCRIPT)
      return window.innerHeight + window.scrollY >=
        document.documentElement.scrollHeight - 10;
    JAVASCRIPT

    break if at_bottom
  end
end

profile_path = File.expand_path(
  ENV.fetch("SELENIUM_PROFILE", "./selenium-profile")
)

FileUtils.mkdir_p(profile_path)

options = Selenium::WebDriver::Chrome::Options.new

options.add_argument(
  "--disable-blink-features=AutomationControlled"
)
options.add_argument("--window-size=1440,900")
options.add_argument("--user-data-dir=#{profile_path}")

proxy = ENV["SELENIUM_PROXY"]

unless proxy.to_s.empty?
  options.add_argument("--proxy-server=#{proxy}")
end

driver = Selenium::WebDriver.for(
  :chrome,
  options: options
)

begin
  driver.navigate.to("https://example.com")

  wait = Selenium::WebDriver::Wait.new(timeout: 10)

  heading = wait.until do
    element = driver.find_element(css: "h1")
    element if element.displayed?
  end

  pause_between_actions(min: 1.0, max: 2.5)
  puts heading.text

  scroll_page(driver)
  pause_between_actions
ensure
  driver.quit
end

Chrome stores cookies, local storage, cache, and other session data in that directory. The next run starts with the same stored session data instead of an empty profile.

Keep the main parts of the session aligned:

  • reuse the same profile across related runs;
  • keep one IP for the duration of a browsing session;
  • rotate the IP between sessions rather than between every page;
  • avoid using one cookie profile simultaneously from several countries or IP addresses.

Over-rotation can be counterproductive. Keeping the same cookies while jumping between unrelated locations may resemble account sharing or session theft. Constantly changing IPs while discarding all cookies can also look like a stream of disposable browser sessions.

Do not open the same Chrome profile from multiple Selenium processes at once. Chrome may lock the directory or corrupt its contents, so give each concurrent worker a separate profile.

A persistent profile may contain login cookies and other sensitive data. Do not commit the directory to Git or share it between unrelated projects.

These changes do not make Selenium indistinguishable from a person. They remove a few unnecessarily mechanical patterns and keep the network and browser session more consistent. For a broader introduction to page parsing, selectors, and HTTP clients, see web scraping with Ruby.

Stop retrying when the site blocks you

Do not retry a blocked page in a tight loop. Repeatedly refreshing the same CAPTCHA or challenge from one browser session can make the IP and session reputation worse rather than improve your chances.

Use a small retry limit and exponential backoff:

def retry_delay(attempt)
  [2**attempt + rand, 60].min
end

This produces progressively longer delays while capping the wait at 60 seconds.

You should also save enough information to understand what the website actually returned:

def save_debug_artifacts(driver, attempt)
  timestamp = Time.now.utc.strftime("%Y%m%d-%H%M%S")
  prefix = "blocked-#{timestamp}-#{attempt}"

  driver.save_screenshot("#{prefix}.png")
  File.write("#{prefix}.html", driver.page_source)

  warn "Current URL: #{driver.current_url}"
  warn "Page title: #{driver.title.inspect}"
  warn "Saved #{prefix}.png and #{prefix}.html"
end

A simple retry loop can then stop after a few unsuccessful attempts:

max_attempts = 3
attempt = 0

begin
  attempt += 1

  driver.navigate.to("https://example.com")

  wait = Selenium::WebDriver::Wait.new(timeout: 10)

  wait.until do
    driver.find_element(css: "h1").displayed?
  end
rescue Selenium::WebDriver::Error::TimeoutError
  save_debug_artifacts(driver, attempt)

  raise if attempt >= max_attempts

  delay = retry_delay(attempt)
  warn "Page did not load as expected. Retrying in #{delay.round(1)} seconds."

  sleep delay
  retry
end

A timeout does not automatically mean CAPTCHA. The saved screenshot and HTML may reveal a rate-limit page, login wall, cookie consent screen, regional restriction, proxy error, or an actual anti-bot challenge.

Use target-specific checks where possible. For example, inspect the expected heading or content instead of assuming that any HTTP-successful navigation returned the page you wanted.

After a small number of failures, stop the run. Switching to another IP and immediately repeating the same high-frequency behavior is not a real recovery strategy—it only burns more proxies and creates more suspicious sessions.

The honest limit of Ruby stealth

Here is the blunt version: Ruby does not currently have a widely used, maintained stealth package comparable to Python’s undetected-chromedriver or selenium-stealth. With Ruby Selenium, you are mostly patching detection signals one at a time.

Each change from the previous steps has a narrow effect:

  • the Blink flag changes the exposed WebDriver state;
  • fingerprint inspection helps identify contradictory browser values;
  • the proxy changes the source IP;
  • the persistent profile keeps cookies and browser storage;
  • explicit waits and pauses remove the most mechanical timing patterns.

None of these changes makes Chrome genuinely indistinguishable from a normal user. Modern bot protection combines browser fingerprints, HTTP headers, JavaScript signals, IP and session reputation, network characteristics, and behavior across multiple requests. TLS fingerprints can also matter, especially when a proxy terminates or changes the connection. Cloudflare uses layered detection that includes JA3/JA4 signals, while DataDome combines browser and network fingerprints with behavioral rules, IP reputation, and machine-learning models.

This setup may reduce CAPTCHA frequency on lightly protected websites. It will not reliably get Selenium past systems such as Cloudflare Bot Management or DataDome, especially after repeated scraping. It also cannot guarantee a good reCAPTCHA v3 score because reCAPTCHA evaluates the wider interaction context rather than checking one JavaScript property.

That is the point where more Chrome arguments stop being a serious strategy. For occasional visible challenges, you can connect a CAPTCHA-solving service and accept the extra latency and cost. For large or heavily protected scraping jobs, a managed scraping API is usually more practical than maintaining an expanding collection of browser patches yourself.

What about CAPTCHA-solving services?

A CAPTCHA-solving service can help with older checkbox and image challenges, but it is not a general solution to bot detection.

For challenges such as reCAPTCHA v2 and hCaptcha, a typical service works like this:

  1. Your script sends the page URL and public site key to the service.
  2. A human worker or machine-learning model completes the challenge.
  3. The service returns a response token.
  4. Selenium places that token into the page and submits the protected form.

The website then verifies the token on its backend. These tokens are normally short-lived and single-use, so the browser must submit them promptly and in the correct page context.

This can make a Selenium CAPTCHA solver useful when an otherwise functional script occasionally encounters a traditional checkbox or image puzzle. However, every solve costs money and usually adds several seconds—or sometimes much longer—to the run. Failed, expired, or rejected tokens also need retry handling.

More importantly, a CAPTCHA-solving service does not repair the browser session that triggered the challenge. It may return a valid challenge token, but your IP reputation, cookies, browser fingerprint, request pattern, and other Selenium signals remain the same. The website may accept the token and then challenge or block the next request because the surrounding session still looks suspicious.

Solvers are less dependable against systems whose token is backed by browser and environment checks rather than a standalone image puzzle. reCAPTCHA v3 evaluates interactions and returns a risk score between 0.0 and 1.0; there is no checkbox or image challenge for an external worker to complete. Google also recommends evaluating the expected action and wider interaction context on the backend. The official reCAPTCHA v3 documentation explains its score-based model.

Cloudflare Turnstile has a similar limitation. It normally runs browser checks in the background and selects a challenge based on signals from the visitor’s environment. It may occasionally display a checkbox, but passing that interaction alone does not guarantee acceptance if the rest of the session still looks automated.

Treat solving services as a narrow, last-resort tool for occasional v2-style challenges—not as a universal way to bypass CAPTCHA with Selenium. If every page produces another challenge, the real problem is probably the browser fingerprint, IP reputation, request pattern, or the decision to use Selenium for that target at all.

When to stop fighting: use a managed anti-bot API

If you are trying to bypass Cloudflare with Selenium in Ruby, there is a point where adding more Chrome flags stops being productive.

You can hide the most obvious WebDriver flag, maintain persistent profiles, rotate residential IPs, and slow down your actions. Ruby makes this especially painful because it has no maintained stealth library that patches the browser for you.

At scale, managing the browser, proxy pool, sessions, retries, and fingerprint consistency quickly becomes more work than extracting the data itself.

A managed web scraping API moves that work outside your Ruby application. ScrapingBee can run the target page in a JavaScript-capable browser and route the request through its proxy infrastructure, including a Stealth proxy pool for difficult websites. You send one HTTP request and receive the rendered HTML.

Here is the same idea using Ruby’s standard library:

require "net/http"
require "uri"

api_key = ENV.fetch("SCRAPINGBEE_API_KEY")
target_url = "https://www.scrapingcourse.com/cloudflare-challenge"

uri = URI("https://app.scrapingbee.com/api/v1")
uri.query = URI.encode_www_form(
  url: target_url,
  render_js: "true",
  stealth_proxy: "true"
)

request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer #{api_key}"

http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.open_timeout = 30
http.read_timeout = 180

response = http.request(request)

unless response.is_a?(Net::HTTPSuccess)
  abort "ScrapingBee returned HTTP #{response.code}: #{response.body}"
end

# These markers are only a heuristic.
# For production scraping, check for content that must
# exist on the target page rather than relying only on generic challenge text.
challenge_markers = [
  "cf-chl-",
  "challenge-platform",
  "cf-turnstile-response",
  "verify you are human",
  "<title>just a moment"
]

body = response.body
body_downcase = body.downcase

if challenge_markers.any? { |marker| body_downcase.include?(marker) }
  abort "The response may still contain an anti-bot challenge."
end

puts body

The API uses Bearer authentication, renders JavaScript by default, and exposes stealth_proxy specifically for difficult targets.

This approach does not solve reCAPTCHA. The goal is to avoid triggering the challenge by presenting a more credible combination of browser execution, network identity, and request handling. A target may still return a CAPTCHA or challenge page, and no configuration guarantees access to every protected website. ScrapingBee’s own Cloudflare guide makes the same limitation clear: browser rendering and proxy rotation can reduce or handle many challenges, but not every CAPTCHA or Turnstile check can be passed automatically.

That distinction matters:

  • a CAPTCHA solver tries to complete a challenge after it appears;
  • a managed anti-bot API tries to prevent the request from looking suspicious enough to trigger one.

For more detail, see the guides on getting past Cloudflare and Turnstile and avoiding reCAPTCHA and hCaptcha while scraping.

Stealth requests are also more expensive than ordinary API calls. At the time of writing, a request using the Stealth proxy pool costs 75 API credits and requires JavaScript rendering, so it should be reserved for targets that actually need it. Check the current ScrapingBee pricing and test the target before designing a large crawl around it.

The Selenium techniques in this guide are still useful for small jobs, testing, and lightly protected websites. But when CAPTCHA avoidance becomes a permanent infrastructure project, calling a managed API is usually the more pragmatic Ruby solution.

Skip the CAPTCHA arms race

Hardening your Ruby Selenium script can prevent the easiest CAPTCHA triggers, but it does not end the anti-bot arms race. On heavily protected sites and larger scraping jobs, let ScrapingBee handle Stealth proxies, browser rendering, and the anti-bot layer through a single Ruby HTTP request. This can substantially reduce how often CAPTCHA challenges appear.

Try ScrapingBee for free — get 1,000 API credits, no credit card required

Selenium CAPTCHA FAQs

Is there a way to bypass a CAPTCHA with Selenium?

The most reliable approach is to avoid triggering the CAPTCHA rather than trying to solve it. Reduce obvious Selenium signals, reuse a consistent browser session, and use an IP with a good reputation. Older reCAPTCHA v2 or hCaptcha challenges can sometimes accept a token returned by a solving service, but behavioral systems such as reCAPTCHA v3 cannot be bypassed reliably this way.

Is bypassing CAPTCHA illegal?

Not automatically, but it may violate the website’s Terms of Service and, depending on the jurisdiction and circumstances, laws such as the CFAA. This is not legal advice. Only collect public data you are permitted to access, respect the site’s rules, and never bypass CAPTCHAs protecting logins, payments, ticket sales, or private accounts.

Why does Selenium keep triggering CAPTCHAs?

Because the browser may expose automation signals, and its IP address may already have a poor reputation. Selenium can reveal navigator.webdriver, ChromeDriver adds recognizable automation behavior, and cloud or heavily reused datacenter IPs are commonly flagged. Fixing these issues can reduce CAPTCHA frequency, but no single change removes every detection signal.

Can you solve reCAPTCHA v3 or Cloudflare Turnstile?

Not reliably. These systems evaluate browser characteristics, session history, IP reputation, and behavior instead of presenting a simple image puzzle. The practical approach is to avoid triggering them by keeping the browser fingerprint, cookies, IP, and browsing behavior consistent.

Is there an undetected-chromedriver for Ruby?

I could not find a widely adopted, actively maintained Ruby equivalent. undetected-chromedriver is a Python Selenium package, while nodriver is its separate Python successor. In Ruby, you must harden Selenium manually with Chrome::Options, consistent browser profiles, proxies, and careful session handling, which covers fewer signals than a dedicated stealth library.

How do I handle CAPTCHA in my own Selenium tests?

Do not bypass it; remove it from the test path. If you own the application, use the provider’s official test keys, such as Google’s reCAPTCHA test keys or Cloudflare’s Turnstile dummy keys or disable CAPTCHA verification in your test environment. For automation against a third-party application, ask its owner for a test account, API, allowlisted environment, or another approved access method.

image description
Ilya Krukowski

Ilya is an IT tutor and author, web developer, and ex-Microsoft/Cisco specialist. His primary programming languages are Ruby, JavaScript, Python, and Elixir. He enjoys coding, teaching people and learning new things. In his free time he writes educational posts, participates in OpenSource projects, tweets, goes in for sports and plays music.

Tests proxy tiers automatically, charges only for success

Try Auto-Mode