league/oauth2-github
GitHub OAuth 2.0 provider for the League OAuth2 client. Easily add GitHub login and API authorization to PHP applications with a simple, standards-based integration built on league/oauth2-client.
Minimal steps
composer require league/oauth2-github
https://app.test/auth/github/callback. Copy the client ID and generate a client secret.config/services.php:'github' => [
'client_id' => env('GITHUB_CLIENT_ID'),
'client_secret' => env('GITHUB_CLIENT_SECRET'),
'redirect' => env('GITHUB_REDIRECT_URI'), // must match GitHub exactly
],
// app/Providers/AppServiceProvider.php
$this->app->bind(\League\OAuth2\Client\Provider\Github::class, fn () => new \League\OAuth2\Client\Provider\Github([
'clientId' => config('services.github.client_id'),
'clientSecret' => config('services.github.client_secret'),
'redirectUri' => config('services.github.redirect'),
]));
Where to look first
League\OAuth2\Client\Provider\Github — getAuthorizationUrl(), getAccessToken(), getResourceOwner(). That's the whole surface you'll use.League\OAuth2\Client\Provider\GithubResourceOwner — tells you which fields (getId(), getNickname(), getName(), getEmail(), getImageUrl()) are safely available, so you don't hand-parse toArray().GET /user docs, because everything you persist comes from that one payload.First use case — "Sign in with GitHub"
// routes/web.php
Route::get('/auth/github', [GithubController::class, 'redirect']);
Route::get('/auth/github/callback', [GithubController::class, 'callback']);
// controller
public function redirect(Request $request, Github $github)
{
$url = $github->getAuthorizationUrl(['scope' => ['read:user', 'user:email']]);
// oauth2-client does NOT persist state for you
$request->session()->put('oauth2state', $github->getState());
return redirect($url);
}
public function callback(Request $request, Github $github)
{
abort_unless($request->filled('code'), 400);
abort_unless(
$request->session()->pull('oauth2state') === $request->query('state'),
403, 'Invalid OAuth state'
);
try {
$token = $github->getAccessToken('authorization_code', [
'code' => $request->query('code'),
]);
$owner = $github->getResourceOwner($token);
} catch (\League\OAuth2\Client\Provider\Exception\IdentityProviderException $e) {
report($e);
return redirect('/login')->withErrors('GitHub login failed.');
}
$user = User::updateOrCreate(
['github_id' => $owner->getId()],
['name' => $owner->getName() ?? $owner->getNickname(), 'email' => $owner->getEmail()]
);
Auth::login($user);
return redirect()->intended('/dashboard');
}
Wrap the token, not the raw string. Store an encrypted access token on the user and hydrate it back into an AccessToken so later API calls look the same as during login:
// users migration: github_id (string, unique), github_token (text, encrypted cast)
$token = new \League\OAuth2\Client\Token\AccessToken([
'access_token' => $user->github_token,
]);
$owner = $github->getResourceOwner($token);
Using the encrypted cast (or Crypt::encryptString) matters — these tokens are long-lived and password-equivalent for the granted scopes.
Service-container binding + a small domain service. Keep controllers thin: a GithubAuthService that owns redirect/callback logic and returns your User model. Tests then hit one class instead of routing + session + Guzzle.
Account linking, not just registration. GitHub login is often a secondary auth on an existing account. Pattern: if Auth::check(), attach github_id to the current user and reject if that github_id is already claimed by someone else (unique index does this for you — catch QueryException on 23000).
Consuming the token for real work. Identity is step one; day-to-day you'll call the GitHub API. Do it with Laravel's client and the token you stored, not with the provider:
Http::withToken($user->github_token)
->withHeaders(['User-Agent' => config('app.name'), 'Accept' => 'application/vnd.github+json'])
->get('https://api.github.com/user/repos')
->throw()
->json();
The provider is only for the OAuth handshake; there's no API wrapper in this package.
GitHub Enterprise / multiple apps. Pass 'domain' => 'https://github.mycorp.com/api/v3' for the resource-owner URL, and subclass Github to override getBaseAuthorizationUrl() / getBaseAccessTokenUrl() (see gotchas — domain alone is not enough). For multiple OAuth apps, register named bindings (github.work, github.personal) rather than mutating one singleton.
Testing. AbstractProvider accepts an httpClient collaborator, so inject a Guzzle MockHandler:
$mock = new \GuzzleHttp\Handler\MockHandler([
new \GuzzleHttp\Psr7\Response(200, [], json_encode(['access_token' => 'fake'])),
new \GuzzleHttp\Psr7\Response(200, [], json_encode(['id' => 42, 'login' => 'octocat'])),
]);
$github = new Github([...], ['httpClient' => new \GuzzleHttp\Client(['handler' => $mock])]);
Note this means Http::fake() does not intercept these calls.
2.0.0 is a platform bump, not a feature release. The only changes are "add PHP 7.1 support" and "remove PHP 5.5 support" — the Github class is still the same ~80-line wrapper around the same getAuthorizationUrl() / getAccessToken() / getResourceOwner() surface, and the league/oauth2-client ^2.0 constraint is unchanged. Dropping PHP 5.5 is technically breaking, but no Laravel app you'd deploy today is affected, so nothing in your integration needs re-auditing. Treat it as confirmation the package still installs on modern PHP, not as new capability. Since nothing else has been added or fixed upstream, still plan your exit: (a) laravel/socialite, which ships a GitHub driver and handles state for you; (b) vendoring the provider class into app/; or (c) writing your own AbstractProvider subclass. If you're on stock GitHub.com and don't need lower-level control, Socialite is the pragmatic default — this package earns its place when you want the League provider directly, need a non-Laravel app to share the same code, or need Enterprise URLs.
state is not persisted for you. getAuthorizationUrl() sets $provider->getState() in memory; on the callback request that memory is gone. You must save it to the session (or a signed cookie) and compare it yourself. Forgetting this is the #1 "it worked locally, fails in prod" bug, because it silently succeeds when you never validate — a CSRF hole.
The domain option only rewrites /user. Authorization and token URLs stay pointed at github.com. For GitHub Enterprise, subclass and override both getBaseAuthorizationUrl() and getBaseAccessTokenUrl() to https://<host>/login/oauth/....
Scopes go in the authorization call, not the constructor. $github->getAuthorizationUrl(['scope' => ['repo', 'user:email']]). In the 1.x API they were a constructor key; older blog posts will mislead you.
getEmail() can be null. GitHub hides email when the user's profile email is private. You need the user:email scope and a manual call to /user/emails to find the primary && verified entry. Design your users.email column to be nullable, or block login until an email is supplied.
Tokens don't expire and there is no refresh token. getRefreshToken() returns null, getExpires() returns null. Don't build refresh logic. Do handle revocation: when a user disconnects, DELETE /applications/{client_id}/token so the grant doesn't linger.
Redirect URI must match byte-for-byte. GitHub compares against the registered callback including scheme, trailing slash, and localhost vs 127.0.0.1. Behind a proxy, make sure APP_URL and Laravel's trusted-proxy config produce the same host you registered.
Missing User-Agent = 403 from the GitHub API. Every request outside the OAuth flow needs one; Laravel's Http client doesn't set it.
Two getResourceOwner() calls = two API requests. Cache the owner in the session for the current request, or once()-wrap it. Unauthenticated rate limits (60/hr per IP) are irrelevant here, but a token-scoped app hitting /user on every page load is wasted latency.
Exception surface is narrow. Catch IdentityProviderException (bad code, revoked secret, scope denial). Any network/transport error surfaces as GuzzleHttp\Exception\GuzzleException instead, because the provider only wraps non-2xx API responses — wrap both if you want a single Login failed path.
Extension points are cheap. ACCESS_TOKEN_RESOURCE_OWNER_ID controls how getResourceOwner() populates getId(), and overriding getResourceOwnerDetailsUrl()/createResourceOwner() lets you swap in stricter typed value objects (e.g. an enum for plan tier) without touching the auth flow. Because it's a small, MIT-licensed class that only receives PHP-version bumps, forking is a legitimate strategy rather than a last resort.
How can I help you explore Laravel packages today?