TIL: Curling Private GitHub Release Assets

2026/04/03

BUILDcidockergithub-actions

I needed to download NCCL .deb packages from a private GitHub repo in a Dockerfile. No gh CLI available. Just curl. Turns out you can’t just hit the download URL directly. You need the API asset endpoint, and it’s because of SAML SSO.

What doesn’t work: the direct download URL

My first instinct was to curl the browser_download_url (the human-friendly URL from the releases page):

curl -fSL \
  -H "Authorization: token $TOKEN" \
  "https://github.com/ORG/REPO/releases/download/TAG/file.deb" \
  -o file.deb

This returns a 302 redirect to SAML SSO: github.com/enterprises/ORG/sso?return_to=.... Not the file. Not even an auth error. Just a redirect to a browser login page that curl can’t complete.

Adding Accept: application/octet-stream doesn’t help. Same redirect.

Why: github.com vs api.github.com

The github.com domain is the web UI. It enforces browser-based SAML SSO for enterprise orgs. When your token isn’t SAML-authorized (and gh auth token gives you an OAuth token that isn’t), GitHub doesn’t reject you with a 401. It redirects you to the SSO login page, as if you’re a browser that needs to authenticate.

The api.github.com domain handles enterprise auth differently. It accepts token auth directly and bypasses the SSO redirect. Same token, different door.

What works: the API asset endpoint

Two steps. Get the asset ID, then download via the API:

TOKEN="your_github_pat"

# Step 1: get the asset ID
ASSET_ID=$(curl -s \
  -H "Authorization: token $TOKEN" \
  "https://api.github.com/repos/ORG/REPO/releases/tags/TAG" \
  | grep -B3 '"your-file.deb"' \
  | grep '"id"' | head -1 | grep -o '[0-9]*')

# Step 2: download via the asset API
curl -fSL \
  -H "Authorization: token $TOKEN" \
  -H "Accept: application/octet-stream" \
  "https://api.github.com/repos/ORG/REPO/releases/assets/$ASSET_ID" \
  -o file.deb

The grep chain is ugly but avoids a jq dependency. If you have jq:

ASSET_ID=$(curl -s \
  -H "Authorization: token $TOKEN" \
  "https://api.github.com/repos/ORG/REPO/releases/tags/TAG" \
  | jq '.assets[] | select(.name == "your-file.deb") | .id')

How the auth flow works

  1. You hit api.github.com/repos/.../releases/assets/ID with your token and Accept: application/octet-stream
  2. GitHub verifies your token has repo access
  3. GitHub responds with 302 redirect to a time-limited, signed Azure Blob URL (release-assets.githubusercontent.com/...?sig=...)
  4. curl -L follows the redirect
  5. Azure Blob Storage serves the binary. The signature baked into the URL is the auth for this leg.

Without the token in step 1, GitHub returns 404 for private repos. Not 401, not 403. Just 404, like the repo doesn’t exist.

The Accept: application/octet-stream header triggers the 302 redirect to the binary. Without it, the API returns JSON metadata about the asset.

How to verify which path you’re on

# SSO redirect (broken path):
curl -v -H "Authorization: token $TOKEN" \
  "https://github.com/ORG/REPO/releases/download/TAG/file.deb" \
  -o /dev/null 2>&1 | grep location
# → enterprises/ORG/sso?return_to=...

# Signed blob URL (working path):
curl -v -H "Authorization: token $TOKEN" \
  -H "Accept: application/octet-stream" \
  "https://api.github.com/repos/ORG/REPO/releases/assets/$ASSET_ID" \
  -o /dev/null 2>&1 | grep location
# → release-assets.githubusercontent.com/...?sig=...

If you see sso?return_to= in the location header, you’re hitting the web UI path. If you see release-assets.githubusercontent.com with a signed URL, you’re on the API path and the download will work.

Gotcha: backslash line continuations

Multiline curl commands with \ can break when pasted into a terminal. The backslash becomes literal instead of a continuation character, and curl sees multiple broken URLs. If you get “URL rejected: Malformed input,” collapse the command to a single line first.