Fix "Sign in to confirm you're not a bot" when fetching YouTube transcripts on a server

Your transcript code works on your laptop. You deploy it to AWS, Vercel, Render, Railway or a VPS, and every video fails with one of these:

ERROR: [youtube] dQw4w9WgXcQ: Sign in to confirm you're not a bot. Use --cookies-from-browser or --cookies for the authentication.
youtube_transcript_api._errors.RequestBlocked / IpBlocked
playabilityStatus: {"status": "LOGIN_REQUIRED", "reason": "Sign in to confirm you're not a bot"}

Nothing is wrong with your code. YouTube decided the request came from a bot because of where it came from. Here is what is going on, how to tell it apart from a video that really is private, and the fixes that work, roughly from free to paid.

Why it only happens on servers

YouTube scores each request by its IP address. Home and mobile connections look like people. Cloud and datacenter ranges (AWS, Google Cloud, Azure, Hetzner, DigitalOcean, Vercel's functions) look like scrapers, so YouTube asks them to sign in before it serves the player data that captions come from. A few requests a day may get through; past a few hundred, most of them won't. That is why it seems random at first and then fails on every video.

Is it the bot check, or is the video really private?

Both come back as LOGIN_REQUIRED, which is why the error is confusing. Look at the reason text:

Reason textWhat it meansRetry?
Sign in to confirm you're not a botYour IP is flagged. The video is fine.Yes, from another IP or client
This video is private / Sign in to confirm your age / members-onlyThe video really needs an accountNo
Video unavailableDeleted or blocked in your regionNo

Checking that string before you retry saves you from burning proxy bandwidth on videos that will never work.

Fixes, from free to paid

1. Update yt-dlp and give it a JavaScript runtime (free, try this first)

Recent yt-dlp versions need an external JavaScript runtime to talk to YouTube. Without one, extraction fails in ways that can look like the bot check. Install Node.js or Deno, then:

python -m pip install -U "yt-dlp[default]"
echo '--js-runtimes node' >> ~/.config/yt-dlp/config
yt-dlp --write-subs --write-auto-subs --sub-langs "en.*" --skip-download "https://youtu.be/dQw4w9WgXcQ"

If it was a missing runtime, this fixes it. If it was the IP, it won't, and you need one of the options below.

2. Ask for the player data as a different client (free)

YouTube serves several apps (web, mobile, TV), and the bot check isn't applied the same way to all of them, so a video blocked for one client often loads for another. In yt-dlp that's --extractor-args "youtube:player_client=..."; the yt-dlp wiki lists which clients currently work, and that list changes. The free, MIT-licensed yt-transcript-cli does this automatically for captions (Android, then iOS, then web):

npx yt-transcript-cli "https://youtu.be/dQw4w9WgXcQ" --json

It exits with code 2 and BLOCKED when every client is refused, so a script can tell the difference between "blocked" and "this video has no captions" (exit 3). On a heavily flagged IP all clients can be refused, and then you need a different IP.

3. Cookies from a logged-in account (free, fragile)

yt-dlp --cookies cookies.txt --write-auto-subs --skip-download "URL"

This is what the error message suggests, and it works for a while. The downsides: cookies expire and need refreshing, every request is tied to a real Google account, and that account can be restricted for automated use. Use a throwaway account, never your main one.

4. Proof-of-origin tokens (free, more setup)

yt-dlp supports PO tokens through provider plugins. They can clear the check without cookies, but you are running and maintaining another moving part that YouTube changes regularly. The yt-dlp wiki's PO Token Guide covers it.

5. Route requests through residential IPs (paid)

Residential proxies make your requests look like home connections. youtube-transcript-api supports them directly:

from youtube_transcript_api import YouTubeTranscriptApi
from youtube_transcript_api.proxies import GenericProxyConfig

ytt = YouTubeTranscriptApi(proxy_config=GenericProxyConfig(
    https_url="http://USER:PASS@your-residential-proxy:PORT"))
transcript = ytt.fetch("dQw4w9WgXcQ", languages=["en"])

It also has a WebshareProxyConfig helper. Rotating residential bandwidth is the reliable fix, and also the expensive one. One developer who hit the block at a few hundred requests a day reported going through about $200 of residential proxy in a month, and still had to babysit the retry logic.

6. Use a hosted transcript API (paid per use)

If transcripts are one step in your product rather than the product itself, it is usually cheaper to call a service that already handles client rotation, residential retries and the reason-string checks. I run two versions of one (disclosure: they're mine):

Other hosted options exist too; whichever you pick, check that a failed or caption-less video isn't billed.

Which one should you use?

None of these is permanent. YouTube changes its checks every few weeks, so whatever you choose, make your code tell "blocked, retry later" apart from "this video has no transcript" so it fails loudly instead of quietly returning nothing.

Updated October 2026. Spotted something out of date? Open an issue on yt-transcript-cli.