Skip to content

Fixing 403 errors

voc0der edited this page Jul 28, 2026 · 1 revision

Explanation

A 403 looks like this in the logs:

ERROR: unable to download video data: HTTP Error 403: Forbidden

The wording matters. yt-dlp successfully extracted the video info — it found the formats, the title, the duration — and was then refused when it went to fetch the actual media. Nothing upstream looks broken, which is why these are confusing to chase.

Two different causes produce this same message, and they are told apart by when the download dies.

Symptom Likely cause
Fails immediately, 0% No working JavaScript runtime
Fails partway through (e.g. 25%) Client selection / PO token

Fails immediately at 0%

yt-dlp needs a JavaScript runtime to solve YouTube's JS challenge (nsig/signature deciphering). Without one, the media URLs it builds are signed incorrectly and YouTube rejects the very first byte.

Check which runtimes are actually available inside the container:

docker exec -it ytdl bash
export HOME=/tmp DENO_DIR=/tmp/deno XDG_CACHE_HOME=/tmp/cache
yt-dlp -v 'https://www.youtube.com/watch?v=<any video>' 2>&1 | grep -iE 'js runtime|JS Challenge'

You want output like this:

[debug] JS runtimes: deno-2.9.4
[debug] [youtube] [jsc] JS Challenge Providers: bun (unavailable), deno, node (unavailable), quickjs (unavailable)

At least one provider must be listed without (unavailable). The official image ships Deno for this purpose.

By default ytdl-material passes no --js-runtimes flag and lets yt-dlp auto-detect, which is the recommended setting. If you have pinned one — via the JavaScript runtimes setting or ytdl_js_runtimes — make sure it matches a runtime that is actually available. Pinning a runtime that isn't installed causes exactly this error.

Fails partway through

If the download gets some way in and then 403s, the JS challenge is being solved fine. The problem is which YouTube client the format came from.

Different clients have different requirements, and YouTube changes them regularly. Some clients now need a PO token for sustained playback: the first few chunk requests succeed, then further requests are refused. Which clients work is not stable over time, so there is no permanent right answer here — you have to test.

Find which client was used by looking for &c= in the media URL in your logs:

&c=TVHTML5&      <- tv client
&c=ANDROID_VR&   <- android_vr client
&c=WEB&          <- web client

Then test clients directly against a video that is failing for you:

export HOME=/tmp DENO_DIR=/tmp/deno XDG_CACHE_HOME=/tmp/cache

# let yt-dlp choose (try this first)
yt-dlp -f 'bestvideo*+bestaudio/best' -o '/tmp/a.%(ext)s' 'https://www.youtube.com/watch?v=...'

# pin a specific client
yt-dlp --extractor-args 'youtube:player_client=android_vr' \
  -f 'bestvideo*+bestaudio/best' -o '/tmp/b.%(ext)s' 'https://www.youtube.com/watch?v=...'

Test while signed in the same way the app runs. If you use a cookies.txt, add --cookies to the test commands — authenticated sessions behave differently from anonymous ones.

In most cases letting yt-dlp choose is best. Only pin a client if you have confirmed a specific one works better on your setup.

Pinning a client in ytdl-material

Once a client works in testing, set it in Settings → Downloader → Global custom args. Args there are delimited by two commas (,,), not spaces:

--extractor-args,,youtube:player_client=android_vr

Anything you set there takes precedence and is never overwritten by the app. It applies to home page and subscription downloads alike. You can also set per-subscription custom args on an individual subscription.

Note: older versions had a Use extractor client fallback checkbox that applied youtube:player_client=tv,web. It has been removed — that fixed list stopped working and began causing the 403s it was meant to prevent. Set the client yourself as above if you need one.

Seeing what is actually sent to yt-dlp

Set the log level to debug (ytdl_log_level: 'debug') and the full argument list is logged, with credentials and cookie paths redacted:

docker logs ytdl 2>&1 | grep -E 'yt-dlp (args|streaming args):' | tail -5

This is the fastest way to confirm which args, formats, and extractor args a download actually ran with, rather than inferring it.

Additional solutions

  • Keep yt-dlp current. The official image installs it at build time, so a stale image means a stale yt-dlp — rebuild or pull before debugging anything else. Check with yt-dlp --version.
  • Upload a cookies.txt in the Advanced tab. Some videos require a signed-in session.
  • Try the browser impersonation option in the Downloader settings if your image has the dependencies enabled.
  • If you are also seeing rate limiting, see Fixing 429 Errors.

Clone this wiki locally