> ## Documentation Index
> Fetch the complete documentation index at: https://code.storage/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Proxy Git with Your Own Auth

> Give your users Git remotes on your own domain. Authenticate each request with your own system, then forward it to Code Storage with a short-lived token.

Use this pattern when you give your users direct Git access. Your users clone and push with a remote
on your domain, such as `https://git.your-app.com/team/project-alpha.git`. Your server authenticates
each request with your own system and forwards it to Code Storage with a short-lived token.

Your users never receive a Code Storage token or your private key. You control:

* **Authentication.** Use your own sign-in, access tokens, or SSO.
* **Authorization.** Decide which user can read or write each repository, and set
  [ref policies](https://code.storage/docs/repos/ref-policies.md) for each user.
* **Revocation.** When you remove access in your system, the next request fails.
* **Access logs.** Record the user, IP address, repository, and operation of each request.

## Choose an approach

A proxy gives you the most control, but it is also the most work: you run a server in the path of
every clone and push. Two lighter options do not need a proxy:

* **Short-lived remote URLs.** Your backend gives each user a remote URL with a short-lived token.
  See [Git remote URLs](https://code.storage/docs/sdks.md#git-remote-urls) on the SDK page.
* **A Git credential helper.** You ship a
  [credential helper](https://git-scm.com/docs/gitcredentials) that gets a short-lived token from
  your API each time Git needs credentials.

With these options, your users hold a Code Storage token until it expires, and their requests go
directly to Code Storage. Use a proxy when your users must never hold a token, or when you must
check, revoke, and log each request.

## How it works

```text theme={"theme":{"light":"github-light","dark":"min-dark"}}
git ──▶ your proxy ──▶ your-org.code.storage
        1. Authenticate the user.
        2. Check access to the repository.
        3. Sign a token for this request.
        4. Forward the request and stream the response back.
```

Your proxy must:

* Forward only the Git endpoints below, and return `404` for every other path.
* Return `401` when the user's credentials are missing or incorrect, and `404` when the user cannot
  access the repository.
* Sign a new token for each request, with the repository, the scopes that the request needs, your
  user ID as `sub`, and your ref policies.
* Never forward the user's `Authorization` header to Code Storage.
* Stream the request body and the response body.

## The endpoints that Git uses

Git over HTTPS uses four requests for each repository, under `/{repo}.git/`:

| Request | Git command | Scopes |
| - | - | - |
| `GET info/refs?service=git-upload-pack` | `clone`, `fetch` | `git:read` |
| `POST git-upload-pack` | `clone`, `fetch` | `git:read` |
| `GET info/refs?service=git-receive-pack` | `push` | `git:read`, `git:write` |
| `POST git-receive-pack` | `push` | `git:read`, `git:write` |

> [!NOTE]
>
> The example proxy does not support [Git LFS](https://code.storage/docs/repos/git-lfs.md). It returns `404` for LFS requests.
> To add LFS, see [Support Git LFS](#support-git-lfs).

## Build the proxy

The proxy is a [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) handler: it
takes a `Request` and returns a `Response`. This example runs it on Node.js with
[srvx](https://srvx.h3.dev) and signs tokens with [`jose`](https://github.com/panva/jose). Install
the packages:

```bash theme={"theme":{"light":"github-light","dark":"min-dark"}}
pnpm add jose srvx
```

Set `PIERRE_ORG` and `PIERRE_PRIVATE_KEY` as shown in [Authentication](https://code.storage/docs/platform/authentication.md).

Each step below adds code to `proxy.ts`, in order. The [complete file](#the-complete-proxy) is at
the end of this section.

### Set up

```typescript proxy.ts theme={null} theme={"theme":{"light":"github-light","dark":"min-dark"}}
import { importPKCS8, SignJWT } from 'jose';
import { serve } from 'srvx';

// Your own auth logic. See "Connect your user system".
import { authenticate, authorize } from './auth.ts';

const org = process.env.PIERRE_ORG!;
const codeStorage = `https://${org}.code.storage`;
const keyPem = process.env.PIERRE_PRIVATE_KEY!;
const signingKey = await importPKCS8(keyPem, 'ES256');

type Access = 'read' | 'write';
```

### Match the Git endpoints

Each route sets the access that its endpoint needs. A request that matches no route gets `404`.

```typescript proxy.ts theme={null} theme={"theme":{"light":"github-light","dark":"min-dark"}}
// The four Git endpoints, and the access that each one needs.
const routes = new Map<string, Access>([
  ['GET info/refs?service=git-upload-pack', 'read'],
  ['POST git-upload-pack', 'read'],
  ['GET info/refs?service=git-receive-pack', 'write'],
  ['POST git-receive-pack', 'write'],
]);

type Route = { repoId: string; path: string; access: Access };

// Match /{repo}.git/{path}. A repository ID can contain slashes.
function matchRoute(req: Request): Route | null {
  const url = new URL(req.url);
  const match = url.pathname.match(/^\/(.+?)\.git\/(.+)$/);
  if (!match) return null;
  const [, repoId, endpoint] = match;
  const path = endpoint + url.search;
  const access = routes.get(`${req.method} ${path}`);
  return access ? { repoId, path, access } : null;
}
```

### Check the user

The handler authenticates the user, checks access to the repository, and writes one log line for
each request. The line has the user, IP address, repository, endpoint, and response status.

```typescript proxy.ts theme={null} theme={"theme":{"light":"github-light","dark":"min-dark"}}
// Handle one Git request. `ip` is the client IP, for the log.
async function handleGit(req: Request, ip: string) {
  const route = matchRoute(req);
  if (!route) return notFound();

  const user = await authenticate(req);
  if (!user) {
    return new Response('Authentication required\n', {
      status: 401,
      headers: { 'WWW-Authenticate': 'Basic realm="git"' },
    });
  }
  // Return 404, so that a user cannot find repositories that
  // they cannot access.
  const { repoId, path, access } = route;
  if (!(await authorize(user, repoId, access))) return notFound();

  const token = await signToken(user.id, repoId, access);
  const url = `${codeStorage}/${repoId}.git/${path}`;
  const res = await forward(req, url, token);

  const entry = { user: user.id, ip, repoId, path };
  console.log(JSON.stringify({ ...entry, status: res.status }));
  return res;
}

function notFound() {
  return new Response('Not found\n', { status: 404 });
}
```

### Connect your user system

Put your own logic in `auth.ts`. It exports two functions:

* **`authenticate(request)`** returns the user, as an object with an `id`, or `null`. Git sends the
  user's credentials as HTTP Basic auth in the `Authorization` header. For `null`, the proxy returns
  `401`, and Git asks the user or its credential helper for credentials.
* **`authorize(user, repo, access)`** returns `true` if the user can access the repository. `access`
  is `'read'` for a clone or fetch, and `'write'` for a push. For `false`, the proxy returns `404`,
  so a user cannot find out which repositories exist.

### Sign a token

The token covers one repository and the scopes that the request needs. It sets `sub` to your user
ID, which Code Storage shows in its logs. The token expires after 300 seconds. It only needs to be
valid when Code Storage receives the request, so a long push still completes.

```typescript proxy.ts theme={null} theme={"theme":{"light":"github-light","dark":"min-dark"}}
// Sign a short-lived Code Storage token for one request.
function signToken(sub: string, repo: string, access: Access) {
  const scopes = ['git:read'];
  if (access === 'write') scopes.push('git:write');

  const now = Math.floor(Date.now() / 1000);
  return new SignJWT({
    iss: org,
    sub, // Your user ID. Code Storage shows it in logs.
    repo,
    scopes,
    // Ref policies for this user.
    refs: [['refs/heads/main', ['no-force-push']]],
    iat: now,
    exp: now + 300,
  })
    .setProtectedHeader({ alg: 'ES256', typ: 'JWT' })
    .sign(signingKey);
}
```

> [!NOTE]
>
> If you use a [restricted key](https://code.storage/docs/platform/authentication.md#restricted-keys), its allowlist must
> include `git:read` and `git:write`. A restricted key also limits the token lifetime to 3,600
> seconds.

### Forward the request

The proxy forwards only the headers that Git needs. It does not forward the user's `Authorization`
header. A clone or push can transfer a large pack, so the proxy streams both bodies and does not
buffer them.

```typescript proxy.ts theme={null} theme={"theme":{"light":"github-light","dark":"min-dark"}}
// The request headers that Git needs.
const requestHeaders = [
  'accept',
  'content-type',
  'content-encoding', // Compressed requests.
  'git-protocol', // Git protocol v2.
  'user-agent',
];
const responseHeaders = ['content-type', 'cache-control'];

// Forward the request with the token. Stream both bodies.
async function forward(req: Request, url: string, token: string) {
  const auth = `Basic ${btoa(`t:${token}`)}`;
  const headers = new Headers({ Authorization: auth });
  for (const name of requestHeaders) {
    const value = req.headers.get(name);
    if (value) headers.set(name, value);
  }

  const res = await fetch(url, {
    method: req.method,
    headers,
    body: req.body,
    duplex: 'half',
  } as RequestInit);

  const resHeaders = new Headers();
  for (const name of responseHeaders) {
    const value = res.headers.get(name);
    if (value) resHeaders.set(name, value);
  }
  const init = { status: res.status, headers: resHeaders };
  return new Response(res.body, init);
}
```

### Serve the proxy

```typescript proxy.ts theme={null} theme={"theme":{"light":"github-light","dark":"min-dark"}}
serve({
  port: 8080,
  fetch: (req) => handleGit(req, req.ip ?? 'unknown'),
});
```

`handleGit()` uses only the Fetch API. To run it on another server, pass it each `Request` and the
client IP address, and return its `Response`.

Run the proxy with Node.js 24 or later. Node.js runs TypeScript files directly. Set
`"type": "module"` in `package.json`.

```bash theme={"theme":{"light":"github-light","dark":"min-dark"}}
node proxy.ts
```

### The complete proxy

> [accordion] proxy.ts
>
> ```typescript proxy.ts theme={null} theme={"theme":{"light":"github-light","dark":"min-dark"}}
> import { importPKCS8, SignJWT } from 'jose';
> import { serve } from 'srvx';
>
> // Your own auth logic. See "Connect your user system".
> import { authenticate, authorize } from './auth.ts';
>
> const org = process.env.PIERRE_ORG!;
> const codeStorage = `https://${org}.code.storage`;
> const keyPem = process.env.PIERRE_PRIVATE_KEY!;
> const signingKey = await importPKCS8(keyPem, 'ES256');
>
> type Access = 'read' | 'write';
>
> // The four Git endpoints, and the access that each one needs.
> const routes = new Map<string, Access>([
>   ['GET info/refs?service=git-upload-pack', 'read'],
>   ['POST git-upload-pack', 'read'],
>   ['GET info/refs?service=git-receive-pack', 'write'],
>   ['POST git-receive-pack', 'write'],
> ]);
>
> type Route = { repoId: string; path: string; access: Access };
>
> // Match /{repo}.git/{path}. A repository ID can contain slashes.
> function matchRoute(req: Request): Route | null {
>   const url = new URL(req.url);
>   const match = url.pathname.match(/^\/(.+?)\.git\/(.+)$/);
>   if (!match) return null;
>   const [, repoId, endpoint] = match;
>   const path = endpoint + url.search;
>   const access = routes.get(`${req.method} ${path}`);
>   return access ? { repoId, path, access } : null;
> }
>
> // Handle one Git request. `ip` is the client IP, for the log.
> async function handleGit(req: Request, ip: string) {
>   const route = matchRoute(req);
>   if (!route) return notFound();
>
>   const user = await authenticate(req);
>   if (!user) {
>     return new Response('Authentication required\n', {
>       status: 401,
>       headers: { 'WWW-Authenticate': 'Basic realm="git"' },
>     });
>   }
>   // Return 404, so that a user cannot find repositories that
>   // they cannot access.
>   const { repoId, path, access } = route;
>   if (!(await authorize(user, repoId, access))) return notFound();
>
>   const token = await signToken(user.id, repoId, access);
>   const url = `${codeStorage}/${repoId}.git/${path}`;
>   const res = await forward(req, url, token);
>
>   const entry = { user: user.id, ip, repoId, path };
>   console.log(JSON.stringify({ ...entry, status: res.status }));
>   return res;
> }
>
> function notFound() {
>   return new Response('Not found\n', { status: 404 });
> }
>
> // Sign a short-lived Code Storage token for one request.
> function signToken(sub: string, repo: string, access: Access) {
>   const scopes = ['git:read'];
>   if (access === 'write') scopes.push('git:write');
>
>   const now = Math.floor(Date.now() / 1000);
>   return new SignJWT({
>     iss: org,
>     sub, // Your user ID. Code Storage shows it in logs.
>     repo,
>     scopes,
>     // Ref policies for this user.
>     refs: [['refs/heads/main', ['no-force-push']]],
>     iat: now,
>     exp: now + 300,
>   })
>     .setProtectedHeader({ alg: 'ES256', typ: 'JWT' })
>     .sign(signingKey);
> }
>
> // The request headers that Git needs.
> const requestHeaders = [
>   'accept',
>   'content-type',
>   'content-encoding', // Compressed requests.
>   'git-protocol', // Git protocol v2.
>   'user-agent',
> ];
> const responseHeaders = ['content-type', 'cache-control'];
>
> // Forward the request with the token. Stream both bodies.
> async function forward(req: Request, url: string, token: string) {
>   const auth = `Basic ${btoa(`t:${token}`)}`;
>   const headers = new Headers({ Authorization: auth });
>   for (const name of requestHeaders) {
>     const value = req.headers.get(name);
>     if (value) headers.set(name, value);
>   }
>
>   const res = await fetch(url, {
>     method: req.method,
>     headers,
>     body: req.body,
>     duplex: 'half',
>   } as RequestInit);
>
>   const resHeaders = new Headers();
>   for (const name of responseHeaders) {
>     const value = res.headers.get(name);
>     if (value) resHeaders.set(name, value);
>   }
>   const init = { status: res.status, headers: resHeaders };
>   return new Response(res.body, init);
> }
>
> serve({
>   port: 8080,
>   fetch: (req) => handleGit(req, req.ip ?? 'unknown'),
> });
> ```

## Use the remote

Give your users the URL of your proxy. Git sends their credentials with HTTP Basic auth, so they can
use a Git credential helper as they do with any other host:

```bash theme={"theme":{"light":"github-light","dark":"min-dark"}}
git clone https://git.your-app.com/team/project-alpha.git
```

When Git asks for credentials, the user enters the credentials that your `authenticate()` accepts,
for example a username and an access token from your product. The proxy matches repository paths
that end in `.git`, so give your users URLs in this form.

## Attribute pushes

[Push webhooks](https://code.storage/docs/repos/webhooks.md) identify the repository and ref, but not the user who pushed. Your
proxy log identifies the user for each `git-receive-pack` request. Use your proxy log as the record
of who pushed.

## Test the proxy

Run these checks before you give the remote to users:

1. Clone a repository through the proxy with a valid user.
2. Push to a branch that the user can write.
3. Clone with an incorrect token. The proxy returns `401`.
4. Clone a repository that the user cannot access. The proxy returns `404`.
5. Push to a ref that a ref policy blocks. Git shows `push denied by policy`.

## Support Git LFS

The example proxy returns `404` for [Git LFS](https://code.storage/docs/repos/git-lfs.md) requests. To support LFS, add the
steps in this section to `proxy.ts`.

> [!WARNING]
>
> Do not forward LFS requests without changes. In the LFS batch response, the actions that point at
> Code Storage contain the `Authorization` header of the request. This header contains the Code
> Storage token of your proxy. Your users then get a Code Storage token, and their LFS transfers do
> not go through your proxy.

### The endpoints that Git LFS uses

The Git LFS client finds the LFS server at your remote URL, under `/{repo}.git/info/lfs/`. It uses
these requests:

| Request | Use | Scopes |
| - | - | - |
| `POST objects/batch` | Get the transfer actions for objects | See below |
| `GET objects/{oid}` | Download an object | `git:read` |
| `PUT objects/{oid}` | Upload an object | `git:read`, `git:write` |
| `POST objects/verify` | Confirm an upload | `git:read`, `git:write` |

A batch request is a `POST` for downloads and for uploads. The `operation` field in the request body
tells which. A `download` batch needs `git:read`. An `upload` batch needs `git:read` and
`git:write`.

### Match the LFS endpoints

Set `PROXY_URL` to the public URL of your proxy, for example `https://git.your-app.com`. The proxy
uses it in the batch response.

```typescript proxy.ts theme={null} theme={"theme":{"light":"github-light","dark":"min-dark"}}
// The public URL of your proxy, for the LFS batch response.
const proxyURL = process.env.PROXY_URL!;

// Match /{repo}.git/info/lfs/objects/{name}, and set the access
// that each LFS endpoint needs. A batch request gets its access
// from its body.
function matchLFSRoute(req: Request): Route | null {
  const url = new URL(req.url);
  const match = url.pathname.match(/^\/(.+?)\.git\/(info\/lfs\/objects\/([^/]+))$/);
  if (!match) return null;
  const [, repoId, path, name] = match;
  if (req.method === 'POST' && name === 'batch') return { repoId, path, access: 'read' };
  if (req.method === 'POST' && name === 'verify') return { repoId, path, access: 'write' };
  if (req.method === 'GET') return { repoId, path, access: 'read' };
  if (req.method === 'PUT') return { repoId, path, access: 'write' };
  return null;
}
```

### Check the user for LFS

The LFS handler is the same as `handleGit()`, with two changes. It reads the access of a batch
request from the body. It also rewrites the batch response.

```typescript proxy.ts theme={null} theme={"theme":{"light":"github-light","dark":"min-dark"}}
// Handle one Git LFS request. `ip` is the client IP, for the log.
async function handleLFS(req: Request, route: Route, ip: string) {
  const user = await authenticate(req);
  if (!user) {
    return new Response('Authentication required\n', {
      status: 401,
      headers: { 'WWW-Authenticate': 'Basic realm="git-lfs"' },
    });
  }

  const { repoId, path } = route;
  let access = route.access;
  const batch = path.endsWith('/batch');
  if (batch) {
    // Use write access for each batch that is not a download.
    const body = await req.text();
    let operation: unknown;
    try {
      operation = JSON.parse(body).operation;
    } catch {
      return new Response('Bad request\n', { status: 400 });
    }
    if (operation !== 'download') access = 'write';
    req = new Request(req.url, { method: req.method, headers: req.headers, body });
  }
  if (!(await authorize(user, repoId, access))) return notFound();

  const token = await signToken(user.id, repoId, access);
  const url = `${codeStorage}/${repoId}.git/${path}`;
  let res = await forward(req, url, token);
  if (batch && res.ok) {
    res = await rewriteBatch(res, token, req.headers.get('authorization')!);
  }

  const entry = { user: user.id, ip, repoId, path };
  console.log(JSON.stringify({ ...entry, status: res.status }));
  return res;
}
```

### Rewrite the batch response

Each action in the batch response has an `href` and a `header`. An action that contains the token of
your proxy points at Code Storage. The proxy changes that action:

* The `href` goes to your proxy, with the same path.
* The `Authorization` header is the user's own header from the batch request. The LFS client sends
  it with the transfer, so your proxy can authenticate the user again.

The proxy does not change the other actions. These are signed object storage URLs, or URLs of the
upstream host for a [synced repository](https://code.storage/docs/repos/github-sync.md). They do not contain your token.

```typescript proxy.ts theme={null} theme={"theme":{"light":"github-light","dark":"min-dark"}}
type LFSAction = { href: string; header?: Record<string, string> };

// Point each action that contains your token at the proxy, and
// replace your token with the user's own credentials.
async function rewriteBatch(res: Response, token: string, userAuth: string) {
  const proxyAuth = `Basic ${btoa(`t:${token}`)}`;
  const body = await res.json();
  for (const object of body.objects ?? []) {
    for (const action of Object.values<LFSAction>(object.actions ?? {})) {
      if (action.header?.Authorization !== proxyAuth) continue;
      action.href = proxyURL + new URL(action.href).pathname;
      action.header = { Authorization: userAuth };
    }
  }
  return Response.json(body, { status: res.status, headers: res.headers });
}
```

### Pass redirects to the client

A download can get a `307` redirect to a signed object storage URL. Return the redirect to the LFS
client, so that the client downloads the object directly. In `forward()`, add `location` to the
response headers, and set `redirect: 'manual'`:

```typescript proxy.ts theme={null} theme={"theme":{"light":"github-light","dark":"min-dark"}}
const responseHeaders = ['content-type', 'cache-control', 'location'];

// In forward():
const res = await fetch(url, {
  method: req.method,
  headers,
  body: req.body,
  duplex: 'half',
  redirect: 'manual', // Return redirects to the client.
} as RequestInit);
```

### Serve LFS requests

Send LFS requests to `handleLFS()`, and all other requests to `handleGit()`:

```typescript proxy.ts theme={null} theme={"theme":{"light":"github-light","dark":"min-dark"}}
serve({
  port: 8080,
  fetch: (req) => {
    const ip = req.ip ?? 'unknown';
    const lfs = matchLFSRoute(req);
    return lfs ? handleLFS(req, lfs, ip) : handleGit(req, ip);
  },
});
```

### Test LFS

Run these checks with a repository that tracks a file with LFS:

1. Push a commit with an LFS file through the proxy.
2. Clone the repository through the proxy. The LFS file has its full content, not a pointer.
3. Clone with a user that can read but not write. The clone succeeds, and a push of an LFS file
   returns `404`.
4. Run `GIT_TRACE=1 git lfs pull`. No LFS request goes directly to `your-org.code.storage`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.