---
title: "Scoped tokens & installations — Git for Laravel | Roundly"
description: "Narrow installation tokens to specific repositories, verify installations as the app itself, and send users to install your GitHub App."
url: https://roundly-consulting.com/open-source/docs/git-for-laravel/installation-tokens
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Git for Laravel](https://roundly-consulting.com/open-source/docs/git-for-laravel.md)

# Scoped tokens & installations

An installation token with no scope reaches every repository the app is installed on, for an hour. Narrow it per operation instead — this is the point of minting rather than storing a credential:

```php
use RoundlyConsulting\Git\Dto\Credentials\GithubAppToken;
use RoundlyConsulting\Git\Dto\Input\InstallationTokenScope;

$scoped = GithubAppToken::for(appId: $id, installationId: $installation, privateKey: $key)
    ->forScope(InstallationTokenScope::forRepositories(repositoryIds: ['40823311']));

Git::github($scoped)->repo('acme/api')->cloneUrl('acme', $scoped);

// Or select by name — equally narrow:
InstallationTokenScope::forRepositories(repositories: ['acme/api', 'acme/web']);
```

- forRepositories() takes its permission set from git.providers.github.app.permissions, so call sites can’t drift from what the app was granted.
- repositoryIds (numeric, survives a rename) and repositories (names) are equally narrow; a non-numeric id throws InvalidCredentialsException.
- forScope() returns a new credential — the original is never narrowed behind someone else’s back.
- The token cache is keyed per scope, so a scoped mint can never be served a wider cached token.

## Deliberately wide scopes

An empty scope is refused outright — a scope that names no repository would mint a token for every repository in the installation, so the package throws rather than silently widening. Two operations genuinely cannot name a repository, and each has a scope that is as wide as it must be and as weak as it can be:

- InstallationTokenScope::metadataOnly() — every repository, metadata: read only. Use it to ask which repositories an installation has.
- InstallationTokenScope::administrationOnly() — every repository, administration: write only. Use it to create a repository, mint it just-in-time, and never store, log or forward it.

```php
Git::github($credentials->forScope(InstallationTokenScope::metadataOnly()))
    ->installationRepositories();          // Page<Repository>

Git::github($credentials->forScope(InstallationTokenScope::metadataOnly()))
    ->allInstallationRepositories();       // LazyCollection<Repository>
```

Passing no scope at all means everything the installation granted, everywhere — almost never what you want. A scope the installation cannot satisfy (unknown repository, ungranted permission) and a vanished installation raise InvalidCredentialsException; a 403 (rate limit, suspension) stays a RequestException, because consumers treat the former as reconnect-required.

## Acting as the app

/app/\*\* endpoints authenticate with the app’s own JWT rather than an installation token — that is how you verify an installation id before trusting it, for instance one that arrived from a browser redirect. Git::githubApp() reads git.providers.github.app.id and private\_key and throws InvalidCredentialsException naming the missing key when either is absent:

```php
$installation = Git::githubApp()->installations()->find($installationIdFromTheRedirect);

$installation->accountLogin;            // "acme-inc"
$installation->accountType;             // e.g. "Organization"
$installation->repositorySelection;     // "all" | "selected"
$installation->permissions;             // ['contents' => 'write', ...]
$installation->reachesEveryRepository();
$installation->isSuspended();

Git::github($credentials)->installationRepositories(); // NOT /user/repos: an
                                                       // installation token 403s there
```

The other app-JWT lookups — every account the app is installed on, and finding an installation by account rather than by id (useful when a customer reinstalls and the stored id goes stale):

```php
$installations = Git::githubApp()->installations();

$installations->all();                       // Page<Installation>
$installations->forOrganization('acme-inc'); // Installation
$installations->forUser('jane-doe');         // Installation
```

The /app/\*\* endpoints require app credentials and the installation endpoints require an installation credential; using the wrong one raises the package’s own InvalidCredentialsException rather than GitHub’s opaque 403. All of them are callable on the Provider type without an instanceof, drivable through Git::fake(), and throw FeatureNotSupportedException on GitLab and Bitbucket.

## Installing the app

Send a person to install the app with installations()->installUrl() (it needs app.slug). GitHub echoes state back to the app’s Setup URL alongside installation\_id, which ties the returning redirect to the request that left:

```php
$url = Git::githubApp()->installations()->installUrl($state); // https://github.com/apps/<slug>/installations/new?state=…
```

## Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

[More ways to support, including crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/git-for-laravel.md)
