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.
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.
Both come back as LOGIN_REQUIRED, which is why the error is confusing. Look at the reason text:
| Reason text | What it means | Retry? |
|---|---|---|
| Sign in to confirm you're not a bot | Your IP is flagged. The video is fine. | Yes, from another IP or client |
| This video is private / Sign in to confirm your age / members-only | The video really needs an account | No |
| Video unavailable | Deleted or blocked in your region | No |
Checking that string before you retry saves you from burning proxy bandwidth on videos that will never work.
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.
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.
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.
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.
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.
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):
curl -X POST "https://api.apify.com/v2/acts/beacondata~youtube-transcript-scraper/run-sync-get-dataset-items?token=YOUR_APIFY_TOKEN" \
-H "Content-Type: application/json" -d '{"videoUrls":["https://youtu.be/dQw4w9WgXcQ"]}'curl "https://youtube-transcript-api23.p.rapidapi.com/transcript?url=https://youtu.be/dQw4w9WgXcQ&format=text" \ -H "x-rapidapi-key: YOUR_RAPIDAPI_KEY" -H "x-rapidapi-host: youtube-transcript-api23.p.rapidapi.com"
Other hosted options exist too; whichever you pick, check that a failed or caption-less video isn't billed.
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.