Zylos LogoZylos
2026-08-08

Machine-Account PATs vs GitHub App Installation Tokens for Fleet-Scale Private Artifact Distribution

githubauthenticationartifact-distributionfleet-managementdevops

Executive Summary

For a fleet of tens to hundreds of self-hosted, non-interactive instances pulling private release binaries from a single GitHub repo, GitHub's own guidance and the mechanics of both credential types point the same direction: GitHub App installation tokens, issued through a small central broker, are the more correct long-term architecture, while a single, tightly-scoped machine-account fine-grained PAT is an acceptable, faster-to-ship interim baseline — provided its single-point-of-failure and 50-token-per-account ceiling are explicitly accepted as known debt. GitHub's own docs state the trade-off plainly: PATs are tied to a human, so "your automation could break if the user no longer has access to the resources you need," whereas a GitHub App "can act independently of users" (Deciding when to build a GitHub App). Deploy keys are correctly excluded — they are SSH-only and are not among the supported credential types for the release-assets REST endpoints (REST API endpoints for release assets). The rest of this article works through the limits, mechanics, real-world patterns, and failure modes that justify this framing.

The Problem Space

The requirement is narrow but has sharp edges: read-only, single-repo, expiring, org-approved, headless credentials that tens to hundreds of independently-operated machines use to pull binary Release assets, with a clear rotation owner. This is squarely a machine-to-machine (server-to-server) authentication problem, not a human-developer-tooling problem — which is exactly the boundary where GitHub's credential taxonomy gets contentious. GitHub currently offers four credential families for repo access: classic PATs, fine-grained PATs, deploy keys, and GitHub App tokens (installation or user-to-server). Classic PATs are excluded by policy (too broad, no fine-grained repo scoping) and deploy keys are excluded per the stated constraint — they authenticate git/SSH operations against a single repo but are not among the documented authentication methods for the Release Assets REST endpoints, which require a PAT or an App token with contents: read (REST API endpoints for release assets). That leaves fine-grained PATs (bound to an account, machine or human) and GitHub App installation tokens as the two live options.

Fine-Grained PATs and Machine Accounts

Limits. Each GitHub account — including a machine account — can hold a maximum of 50 fine-grained personal access tokens; GitHub's own guidance for anyone hitting that ceiling is explicit: "If you require more tokens or are building automations, consider using a GitHub App for better scalability and management" (Managing your personal access tokens). This matters directly at fleet scale: a design that mints one PAT per instance runs out of headroom well before "hundreds," forcing either a shared token across the whole fleet (weak isolation, one leak compromises everyone) or a token-per-environment/ring compromise.

Expiry. Fine-grained PATs support expiration dates, and organizations/enterprises can enforce a maximum lifetime policy — GitHub's default guidance caps this at 366 days, though "infinite" lifetimes are technically selectable unless a policy blocks them (Managing your personal access tokens). GitHub also auto-revokes any PAT unused for a year, which is a helpful but insufficient hygiene backstop, not a substitute for a real rotation ceremony.

Org approval and audit. If the owning organization requires approval for fine-grained PATs, an org owner must explicitly approve the token before it can touch non-public org resources, and owners get a daily digest email of pending requests (Reviewing and revoking personal access tokens in your organization; Setting a PAT policy for your organization). Audit visibility is genuinely good post-GA: token_id is attached to every relevant audit log event and is a first-class filter, and enterprise audit logs can be queried by the token's SHA-256 hash to reconstruct everything a specific compromised token touched (Fine-grained PATs are now generally available; Identifying audit log events performed by an access token).

Machine-account policy status. GitHub's Corporate Terms of Service formally recognize "Machine Accounts": an account "registered by an individual human who accepts the applicable terms of service on behalf of the Machine Account... and is responsible for its actions," usable "exclusively for performing automated tasks," with a limit of one free machine account per human owner in addition to their personal account (GitHub Corporate Terms of Service). Critically, this account is still a seat: if it's added as an org member or outside collaborator on a paid plan, it consumes a licensed seat like any human, and — the failure mode that matters most here — a fine-grained PAT (like a classic PAT) becomes inactive the moment the owning account loses access to the resource it was scoped to (Managing your personal access tokens). An org offboarding event, account suspension, token expiry, or explicit access revocation can therefore interrupt the whole fleet. Fine-grained PATs receive SSO authorization during creation; ordinary browser SSO-session expiry does not itself revoke that authorization. Classic PATs use the separate post-creation Configure SSO flow. Diagnose the actual API response rather than assuming every access failure is HTTP 401 (Authorizing a personal access token for use with single sign-on).

GitHub App Installation Tokens

Creation flow. A GitHub App authenticates in two hops. First it signs a JWT with its private key (RS256, iss = app ID); this JWT has a hard maximum lifetime of 10 minutes (Generating a JSON Web Token (JWT) for a GitHub App). That JWT is then POSTed to /app/installations/{installation_id}/access_tokens to mint an installation access token, which expires after 1 hour and can be scoped down at mint-time via repositories/repository_ids (up to 500 repos) and a narrower permissions object than the app's maximum grant (Authenticating as a GitHub App installation).

Headless-fleet implications of the 1-hour lifetime. This is a non-issue for release-asset pulls specifically: the token is only needed for the initial authenticated GET against the GitHub API; if GitHub responds with a 302 to the underlying blob store, that redirect URL is independently time-boxed and doesn't require the App token at all. The design constraint this pushes onto you is architectural, not operational: no machine can hold a long-lived App credential directly (a private key installed on hundreds of endpoints is itself a huge blast-radius regression), so something has to mint tokens on the fleet's behalf — which is exactly the token-broker pattern below.

Rate limits. The primary bucket belongs to the installation, shared by its installation tokens, rather than to each minted token: a base of 5,000 requests/hour, scaling +50/hour for each repository beyond 20 and, for organization installations, +50/hour for each user beyond 20, capped at 12,500/hour for non-GHEC installations. An installation on a GitHub Enterprise Cloud organization receives 15,000/hour. Secondary limits also apply, including 100 concurrent requests shared across REST and GraphQL, and 900 points/minute for REST endpoints; these are not a promised independent allowance for every token. Minting more tokens does not create installation quota. Size fleet bursts and retries against the shared budget and observe rate-limit responses (Rate limits for the REST API).

Fit for headless fleets / permission model / revocation. GitHub's own comparison guidance is unambiguous here: build a GitHub App "if you want to access GitHub resources... in an organization, or you anticipate a long-lived integration," specifically because a PAT-based integration "could break if the user no longer has access" (Deciding when to build a GitHub App). Permissions are declared at the App level (e.g., contents: read only) and can never be exceeded by a minted token even if requested. Revocation must target the right credential class. The owner of the account or organization where the App is installed can suspend or uninstall that installation to block access to that account's resources (Reviewing and modifying installed GitHub Apps). That installation owner is distinct from the App registration owner, who controls the App and its signing keys. Revoking a user's authorization concerns access on that user's behalf; it is not an installation-token kill switch, because installation and user authorization are independent (Authorizing GitHub Apps). App token events also appear in the audit log alongside PAT events, tagged as app-installation authentication with a token hash (Identifying audit log events performed by an access token). GitHub App bot identities also do not consume a paid org seat, unlike a machine-account PAT added as a member/collaborator.

Release Asset Download Mechanics

Both credential types hit the same endpoint and the same quirks — this is a carrier-agnostic gotcha layer, not a PAT-vs-App difference:

  • Request GET /repos/{owner}/{repo}/releases/assets/{asset_id} with Accept: application/octet-stream (not the default application/vnd.github+json) to get binary content instead of JSON metadata (REST API endpoints for release assets).
  • The API replies with either a 200 (content streamed directly) or a 302 redirect to the object storage location; well-behaved clients must handle both, and any script that only checks for 200 will silently fail on the redirect path.
  • The most commonly reported operational gotcha (recurring across community threads, though GitHub's own docs are notably silent on it) is that the 302 target is a signed, time-boxed URL on a different host, and forwarding your Authorization: Bearer <token> header onto that cross-host redirect breaks the request — the storage backend rejects a request that carries both its own URL-embedded signature and an unrelated GitHub bearer token. Standard HTTP clients that respect cross-origin redirect semantics (curl with -L, Python requests) generally drop the Authorization header on a host change by default and work correctly; clients that naively re-attach the same headers on every hop (some raw fetch/axios configurations, hand-rolled retry wrappers) are the ones that break. Because this behavior isn't spelled out in GitHub's docs, treat it as something to test explicitly against your actual HTTP client/library rather than assume.
  • This mechanic is identical for a fine-grained PAT and an App installation token — the credential only matters for authorizing the first hop.

Fleet Distribution Patterns in the Wild

GitHub's own recommended shape for automation identity is the App-plus-broker pattern, not a bag of long-lived tokens distributed to endpoints. Martin Costello's public write-up of a self-built GitHub token broker is a clean reference implementation of exactly the "central service exchanges an App JWT for a short-lived installation token" pattern the requirement gestures at: an API service validates a caller's OIDC/JWT identity against a declarative policy ("this caller may get a token with contents: read on repo X"), mints a JWT from the App's private key, exchanges it for a scoped installation token, hands back only that token, and lets it expire naturally within the hour (Implementing a custom GitHub token broker). A hardened variant of this keeps the App's private key inside a cloud HSM (e.g., Azure Key Vault) and never lets it touch the broker's disk, signing the JWT via a remote signing call instead (Protect your GitHub App private keys with Azure Key Vault and remote JWT signing). For a fleet of instances, the natural adaptation is: instances authenticate to your own control plane (however you already do that), the control plane holds the one App installation, and it either mints a fresh short-lived installation token per pull request or proxies the download itself.

Registry alternatives have their own gotchas that argue against blindly moving to OCI. GitHub Container Registry (ghcr.io) is a plausible alternative carrier for binary artifacts, but it has credential quirks that cut the other way from what you'd expect: fine-grained PATs are not supported by GHCR at all, and while a GitHub App installation token works for docker login, it does not reliably work for docker pull in practice — leaving classic PATs with read:packages as the only consistently-working option for private image pulls today (Working with the Container registry; community reports in GitHub Community Discussion #38467). This is a strong argument for staying on Release assets over migrating to ghcr.io purely for auth-modernization reasons — the OCI path would ironically force you back to classic PATs.

Offline/fallback pattern: self-managed fleet tooling in this space (e.g., Elastic Agent's Fleet) explicitly supports pointing at a self-hosted artifact mirror instead of the vendor's default source, precisely so that connectivity or credential failures at the source don't strand the whole fleet (Upgrade Elastic Agent). The equivalent here is a periodic job (using the broker or a maintainer PAT) that mirrors the release artifact to an internal object store or CDN, which instances fall back to if GitHub auth is unavailable — allowing already-mirrored artifacts to remain available without GitHub access. New releases still need a successful sync, and instances must be able to reach and authenticate to the mirror independently of the failed GitHub/broker path.

Decision Framework

PAT wins when: you need to ship in days not weeks, the fleet is small (tens, not hundreds) and centrally reachable so a single shared credential's blast radius is tolerable, there's a dedicated machine account with unambiguous ownership (not a human's personal account), org PAT-approval is enabled, and you accept manual rotation before the ≤366-day expiry as a scheduled, owned task.

GitHub App wins when: the fleet is expected to grow past dozens of independently-provisioned pull identities (avoiding the 50-PAT ceiling), you want installation-level access blocking, owned by the installation account or organization and independent of any one human's account lifecycle, you want tokens that self-expire hourly with no manual rotation ceremony, and you're willing to build or adopt the one piece of extra infrastructure a broker requires.

Hybrid (recommended default): ship the fine-grained machine-account PAT now as the interim baseline — it satisfies every hard requirement (read-only, single-repo, expiring, org-approved) — while building the GitHub App + broker as the target architecture. Cut over once the broker exists. For availability during GitHub or broker outages, use the independent artifact mirror described above. A retained break-glass PAT is a different, online fallback: it can bypass a failed broker only while GitHub and the PAT account remain usable. If required, keep it in an operator-controlled secret store with a named rotation/revocation owner and audited use, not as a standing credential on every instance. It preserves an account dependency and must be revoked separately from the App installation; it does not provide offline availability.

Key Takeaways

  1. Deploy keys are correctly ruled out: they're SSH-only and not a supported auth method for the Release Assets REST endpoint (docs).
  2. Fine-grained PATs cap at 50 per account and expire in ≤366 days under a typical org policy — a real ceiling for fleets that provision per-instance tokens (docs).
  3. A PAT — fine-grained or classic — goes dead the instant its owning account loses access to the target resource, which is the single point of failure the requirement is right to worry about (docs).
  4. GitHub's official guidance explicitly recommends Apps over PATs for exactly this scenario: org-scoped, long-lived, user-independent automation (docs).
  5. App installation tokens expire in 1 hour and are minted from a JWT that itself expires in 10 minutes — but this doesn't threaten large-file downloads, since the redirect target to blob storage carries its own separate, signed expiry (docs).
  6. Installation tokens share their installation's primary rate-limit bucket (5,000–12,500/hour, or 15,000/hour when installed on a GHEC organization); extra tokens do not multiply this quota (docs).
  7. The installation account/organization owner can suspend or uninstall the App to block access to that account's resources. Revoking personal App authorization is a separate action for user access, not an installation-wide kill switch (docs).
  8. The 302-redirect-to-blob-storage step is a shared gotcha for both credential types: clients must handle both 200 and 302, and should verify (rather than assume) how their HTTP client handles the Authorization header across the cross-host redirect, since GitHub's docs don't specify this behavior (docs).
  9. Moving to ghcr.io as an alternative carrier doesn't dodge the PAT question — GHCR doesn't support fine-grained PATs at all, and App tokens are unreliable for docker pull, so OCI distribution would likely require classic PATs, a step backward (docs).
  10. A token-broker service (App JWT → short-lived installation token, handed to callers under policy) is a proven, documented pattern for exactly this "many headless machines, one GitHub identity" shape, and can be hardened further by keeping the App's private key in an HSM rather than on the broker's disk (reference implementation, HSM-backed variant).