
Unlisted Repo: Sharing a Private GitHub Repo Without Making It Public
Why I built a GitHub App that turns a private repository into a read-only, account-free link with no credential in the URL — so I could show personal repos on this site without making them public.
A few of the entries on the Code page of this site point at repositories I do not want public. The Hey-Computer assistant, the two Meta SDK occlusion patches — they are personal, half-finished, or carry context I would rather not hand to the whole internet. But I still want to show the code to a recruiter or a fellow dev who asks. "Trust me, it exists" is not a portfolio.
GitHub gives you exactly two states for a repository: public, or private and invisible to anyone who is not a collaborator. There is no "anyone with the link can read this" in between — the thing YouTube has had for unlisted videos for fifteen years. So I built it. github-unlisted.com takes a private repo and produces a browsable, read-only link that needs no GitHub account on the other end and carries no credential in the URL. The "View" links on the Code page are this in use.
This is the story of the auth model, because that is the entire product. The file browser is the easy part.

The options I threw out first
Make the repo public. All-or-nothing, and irreversible in the way that matters: once it has been public and indexed and cloned, making it private again does not un-ring the bell. Not an option for anything with history I care about.
Add the viewer as a collaborator. Wrong shape entirely. It requires them to have a GitHub account, it grants more than read, and it is per-person admin overhead for what should be a link I paste once.
Put a token in the URL. A personal access token or OAuth token in a query string is the obvious hack, and the earlier prototype of this project did exactly that. I scrapped the whole fork. A credential that lives in the link is a credential that lives in browser history, in referer headers, in the analytics of every site the link is pasted into, and in the clipboard of whoever forwards it. The product exists to protect a private repo; shipping the key inside the share defeats the point before you start.
The credential must never leave the server, and the link must be inert on its own.
Why not an existing tool?
Two products already do roughly this, and I genuinely intended to use one rather than build anything. Both fell down for the same underlying reason, which is worth walking through because it is the reason this one is shaped the way it is.
gitshare.me is the one I actually wanted to use. It is a dollar a share — not nothing, but the price is not the problem. The problem is it does not reliably work: it gets stuck on a loading screen and never renders the files for a large fraction of the repos I tried. A sharing tool that intermittently fails to display the thing it is sharing has failed at the only job it has.
On top of that there is no management — once a repo is shared there is no dashboard to change its settings or pull it back. Paying per share for something that might not render and that I cannot administer afterwards is not a foundation I could put a public page on.
githubprivate.link takes the token-in-the-link approach I had already thrown out for my own prototype, and its specific failure is instructive. Because it never authenticates against your GitHub account there is no central dashboard at all — you track every link yourself and remember which token backs which project.
Worse for my case: a link it generates cannot be posted anywhere GitHub can see it. Commit it into a repo or drop it in a Discussion and GitHub's secret scanning detects the embedded token and revokes it. The entire point of mine is that the link is safe to publish on a public website — which is exactly where githubprivate.link's model self-destructs.
Both gaps trace to one root: no GitHub-authenticated account standing behind the share. That account is precisely what buys a central dashboard, per-share revocation, and a link with no secret in it for anyone to scan. It is the difference between a tool I could build this site's Code page on and one I could not.
The model is a GitHub App, not a token
A GitHub App is the right primitive and it took me too long to realise it. The app holds a single private key as a server-only secret. It signs a short-lived JWT, exchanges that for an installation access token scoped to exactly the repositories the owner granted it, and that token lives for about an hour and is auto-refreshed. The owner installs the app once and ticks which repos it can see. From then on every share is managed from a dashboard in the app — list them all, revoke any single one, set or clear its expiry — while GitHub itself stays the master switch for what the app can read at all. Nothing here is a token you minted by hand and now have to remember you minted.
import { createAppAuth } from "@octokit/auth-app";
import { Octokit } from "octokit";
// Server-only. Never import from a client component.
export function getInstallationOctokit(installationId: number): Octokit {
const appId = process.env.GITHUB_APP_ID;
// Vercel stores the PEM with literal \n; turn them back into newlines.
const privateKey = process.env.GITHUB_APP_PRIVATE_KEY?.replace(/\\n/g, "\n");
return new Octokit({
authStrategy: createAppAuth,
auth: { appId, privateKey, installationId },
});
}Every GitHub read in the app goes through an Octokit minted like this, server-side, under the installation token. The browser never sees a token, the URL never carries one, and the rate limit is about 15,000 requests per hour per installation instead of the 5,000 a PAT would give me. The earlier fork had this elaborate dance juggling visitor-IP quota against owner quota. With an installation token that entire subsystem just deleted itself.
The share link is an opaque id and nothing else
A share is a random UUID mapped, in a tiny KV store, to the install and repo it points at. That is the whole record. No credential is in it because the token is minted on demand at view time.
export async function createShare(target: ShareTarget): Promise<string> {
const id = globalThis.crypto.randomUUID();
const redis = getRedis();
await redis.set(`share:${id}`, { ...target, createdAt: Date.now() });
// Reverse index so a webhook can purge an installation's links later.
await redis.sadd(`inst:${target.installationId}`, id);
return id;
}The link is github-unlisted.com/{owner}/{repo}?s=<shareId>. The owner/repo in the path is cosmetic — pretty URLs — the only thing that resolves anything is the opaque s. There is no application database of repository data; the file tree, blobs, branches and commits are all fetched live from the GitHub API server-side at request time and rendered with Server Components. KV holds the mapping and a per-installation reverse index, and that is the entire persistent state.
An optional TTL rides on Redis's own key expiry, so an auto-revoking link is one set with an ex and not a cron job I have to babysit.
You can only share what you actually granted
The create-share endpoint enforces two things before it will mint an id. The signed-in user must control that installation, and the repo must really be in that installation's granted set — not just claimed in the request body.
if (!session.installationIds.includes(installationId)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const repos = await listInstallationRepos(installationId);
if (!repos.find((r) => r.owner === owner && r.name === repo)) {
return NextResponse.json(
{ error: "Repo not in this installation" },
{ status: 403 },
);
}The second check is the one that matters. Without it, anyone signed in could craft a request for someone-else/private-repo against an installation id and the server would happily create a working share. Listing the installation's repositories and confirming membership closes that.
Revocation: webhooks are tidy-up, read-time is the lock
When an owner uninstalls the app or drops a repo from it, I want the links to die. There is a webhook for that — installation deleted/suspended, installation_repositories removed — verified with an HMAC signature and a constant-time compare, that purges the affected entries from KV.
But the webhook is not the security boundary, and treating it as one would be a mistake. Webhooks get missed, delayed, or fail mid-cleanup. The actual enforcement is that a revoked installation can no longer mint a working token — so even a share whose KV record somehow survived resolves to a token that GitHub refuses, and the view fails closed. The webhook handler even swallows its own errors and still returns 200, on purpose:
} catch (e) {
// Still 200 so GitHub does not retry-storm. Read-time token checks
// are the real protection even if this cleanup did not run.
console.error("[webhook] cleanup failed:", e);
}That comment is the whole security posture in two lines. Cleanup is best-effort hygiene.
Access dies the moment the grant dies, because access was never anything but the grant.
The infrastructure landmines
Three things cost me time and are worth recording.
The PEM newline thing. Environment variables flatten a multi-line private key into a string with literal \n sequences. Node's crypto wants real newlines. One .replace(/\\n/g, "\n") at the point of use, and never store the un-normalised form anywhere it gets compared.
Vercel's Upstash integration lies about env var names. The Marketplace integration injects KV_REST_API_URL / KV_REST_API_TOKEN (the old Vercel KV names), not the UPSTASH_REDIS_REST_* names the Upstash SDK examples use. The fix is to read both and not assume — and to use the read-write token, because the read-only KV token silently cannot write and the failure looks like a logic bug.
Windows plus Turbopack panics. next dev with Turbopack crashes with 0xc0000142 inside the Tailwind v4 PostCSS loader on my Windows box. next dev --webpack locally sidesteps it; the Vercel Linux build is unaffected, so it never reaches production. Windows also orphans the dev-server port on stop, which has to be freed before the next run — the same papercut this site's run_localhost.bat exists to handle.
Where this falls short
It is whole-repo, read-only. There is no per-file redaction and no way to share a single directory; if the repo has a secret in its history, sharing it shares that. The viewer is a file browser, not GitHub — no issues, PRs, blame, or Actions. And the webhook-driven KV cleanup is best-effort, so dead share records can accumulate until something prunes them — harmless for access, since a revoked grant fails closed regardless, just untidy over time.
What this enables
The immediate payoff is the Code page on this site. The Hey-Computer repo and the two Meta SDK occlusion patches are private, and the "View" buttons next to them are github-unlisted.com links — opaque id, server-minted token, no account required from whoever clicks. I can show the actual code to anyone I want to, revoke all of it by uninstalling one app, and never expose a key to do it.
The broader lesson, the same one that keeps coming up whenever I build infrastructure: the secure design was also the simpler one. The moment I stopped trying to smuggle a credential to the client and let the server be the only thing that ever holds the key, the visitor-quota juggling, the token-in-URL hygiene problem, and half the revocation logic all stopped being my problem. It is GitHub's unlisted-video feature, and it turned out to be mostly an exercise in giving the credential nowhere to leak to.