---
title: "GitHub App & OAuth tokens — Git for Laravel | Roundly"
description: "Self-refreshing GitHub App installation tokens and OAuth access tokens — minted, cached and refreshed for you, with an event on rotation."
url: https://roundly-consulting.com/open-source/docs/git-for-laravel/github-app-oauth
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)

# GitHub App & OAuth tokens

Static Token credentials never expire, but GitHub App installation tokens and OAuth access tokens do. GithubAppToken and OauthToken mint, cache and refresh the access token transparently — reads, writes and batches all benefit, with secrets redacted from any serialization. The App JWT is a real RS256 JWS signed through crypto-for-laravel — no third-party JWT library.

## GitHub App installation tokens

Configure the app once in .env:

```bash
GITHUB_APP_ID=123456
GITHUB_APP_INSTALLATION_ID=7654321
GITHUB_APP_PRIVATE_KEY=/path/to/app-private-key.pem
GITHUB_APP_SLUG=acme-bot
```

With the app id, installation id and private key all configured, Git::github() builds a GithubAppToken automatically — with any of the three missing it falls back to the static token, or to no credential — and Git::credentials(ProviderName::Github) returns that same credential — ->accessToken() on it reads the live installation token, minting it when the cache is cold. Or build one explicitly:

```php
use RoundlyConsulting\Git\Dto\Credentials\{GithubAppToken, OauthToken};
use RoundlyConsulting\Git\Enums\ProviderName;

$github = Git::github(GithubAppToken::for(
    appId: config('git.providers.github.app.id'),
    installationId: config('git.providers.github.app.installation_id'),
    privateKey: config('git.providers.github.app.private_key'), // PEM string or file path
));
$github->repositories(); // installation token minted, cached to expiry, reused
```

App and installation ids must be numeric — anything else throws InvalidCredentialsException before it can reach a cache key. Use a shared cache store (not the array driver) so minted tokens persist across requests. A GithubAppToken built without an apiBaseUrl mints at the configured git.providers.github.url, so a hand-built credential works on GitHub Enterprise Server too.

## OAuth access tokens

```php
// The OAuth client (client_id, client_secret, token_url) is static per provider, so
// forProvider() takes it from git.providers.github.oauth.* and asks only for the
// per-user half.
$github = Git::github(OauthToken::forProvider(
    ProviderName::Github,
    accessToken: $access, refreshToken: $refresh, expiresAt: $expiresAt,
));

// Pass all five to OauthToken::for() to bypass config entirely.
$gitlab = Git::gitlab(OauthToken::for(
    accessToken: $access,
    refreshToken: $refresh,
    clientId: 'your-client-id',
    clientSecret: 'your-client-secret',
    tokenUrl: 'https://gitlab.com/oauth/token',
    expiresAt: $expiresAt,
));
```

forProvider() throws InvalidCredentialsException naming the missing key when client\_id, client\_secret or token\_url isn’t configured.

A refresh the token endpoint answers with 400 invalid\_grant — the refresh token expired, was revoked or was already used — throws InvalidCredentialsException::refreshTokenRejected(): the user has to reconnect, so it isn’t worth retrying. The forge’s response is getPrevious(); any other 400 stays a RequestException.

## Persisting a rotated refresh token

If you store OAuth credentials, listen for OauthTokenRefreshed. Providers that rotate refresh tokens invalidate the old one the moment they issue a new one. The new token goes into the token cache, whose entry expires with the access token — so once that hour is up, the only copy left anywhere is the one you persisted, and it is dead:

```php
use Illuminate\Support\Facades\Event;
use RoundlyConsulting\Git\Events\OauthTokenRefreshed;

Event::listen(OauthTokenRefreshed::class, function (OauthTokenRefreshed $event) {
    if ($event->rotated()) {
        $connection->update([
            'refresh_token' => $event->refreshToken,
            'access_token' => $event->accessToken,
            'expires_at' => $event->expiresAt,
        ]);
    }
});
```

The event fires on every refresh, rotated or not; rotated() is the flag that says the stored value has to change. Do not log the event whole — it carries live tokens by design.

## Concurrent refreshes

Concurrent workers refresh once: on a cache store that supports locks (Redis, database, file, array, …) the refresh runs under a lock, and a worker that waited for it uses the token the holder just refreshed instead of presenting the spent refresh token again — so a rotating provider never answers invalid\_grant to the loser of the race, and the event fires once.

## 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)
