Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Oauth2 Server Laravel Package

league/oauth2-server

A standards-compliant PHP OAuth 2.0 authorization server supporting authorization code, client credentials, device, implicit, refresh, and password grants. Built on PSR-7 for easy API protection.

View on GitHub
Deep Wiki
Context7

Getting Started

Decide which layer you're adopting first. For 90% of Laravel apps you don't consume league/oauth2-server directly — you consume Laravel Passport, which is this package plus Eloquent repositories, routes, guards and Artisan commands. Reach for the raw library only when Passport's opinions block you: non-Eloquent storage, a custom grant type, an existing users/clients schema you can't migrate, or an OAuth server embedded in a non-Laravel-ish service.

Path A — Passport (default):

composer require laravel/passport
php artisan passport:install        # generates oauth-private.key / oauth-public.key + personal access client
// User model
use Laravel\Passport\HasApiTokens;

// config/auth.php
'guards' => ['api' => ['driver' => 'passport', 'provider' => 'users']],

// AuthServiceProvider::boot()  (routes are auto-registered in recent Passport versions)
Passport::tokensExpireIn(now()->addMinutes(15));
Passport::refreshTokensExpireIn(now()->addDays(30));
Passport::tokensCan(['orders:read' => 'Read orders', 'orders:write' => 'Write orders']);

First use case, end to end: protect /api/user with auth:api, mint a token with $user->createToken('mobile', ['orders:read'])->accessToken, then gate a route with middleware('scope:orders:read'). Ten minutes of work, spec-compliant bearer tokens.

Path B — raw library. The whole integration boils down to six things:

  1. composer require league/oauth2-server (PHP 8.2+, openssl, json).
  2. Two RSA keys + one encryption key: openssl genrsa -out oauth-private.key 2048, openssl rsa -in oauth-private.key -pubout -out oauth-public.key, and base64_encode(random_bytes(32)) for the AES key that protects refresh tokens and auth codes.
  3. Classes implementing ClientRepositoryInterface, AccessTokenRepositoryInterface, RefreshTokenRepositoryInterface, AuthCodeRepositoryInterface, ScopeRepositoryInterface (plus UserRepositoryInterface for password grant, DeviceCodeRepositoryInterface for device flow).
  4. Entity classes that use the shipped traits — ClientTrait, AccessTokenTrait (gives you toString()/convertToJWT()), RefreshTokenTrait, AuthCodeTrait, ScopeTrait.
  5. Bind an AuthorizationServer and enable grants, each with its own DateInterval TTL.
  6. Bind a ResourceServer and wrap it in middleware.
$this->app->singleton(AuthorizationServer::class, function ($app) {
    $server = new AuthorizationServer(
        $app->make(ClientRepository::class),
        $app->make(AccessTokenRepository::class),
        $app->make(ScopeRepository::class),
        new CryptKey(storage_path('oauth-private.key'), null, false),
        new CryptKey(storage_path('oauth-public.key'), null, false),
        config('oauth.encryption_key'),
    );

    $server->enableGrantType(new ClientCredentialsGrant(...), new DateInterval('PT15M'));

    return $server;
});

First use case to prove the wiring: POST /oauth/token with grant_type=client_credentials → assert you get access_token, token_type: Bearer, expires_in. Then hit a protected route and confirm oauth_client_id shows up as a request attribute.

Implementation Patterns

Pick the grant by consumer, not by convenience.

Consumer Grant Notes
First-party SPA / mobile app Authorization code + PKCE Password grant is on the way out (OAuth 2.1 drops it)
Machine-to-machine Client credentials No user, no refresh token
Third-party server-side app Authorization code Confidential client with secret
CLI / TV / kiosk Device authorization Added in v9, DeviceCodeGrant
Internal scripts, webhooks, dev tooling Personal access tokens $user->createToken() — skips the whole dance

Treat ScopeRepository::finalizeScopes() as your authorization chokepoint. It's called on every token issuance and is the only place where you can intersect requested scopes with what the client and user are actually entitled to. Return only entities the repository can resolve, and make ScopeEntityInterface::jsonSerialize() return the bare identifier string — that's why tokens serialize scopes as ["orders:read"]. In Passport this is Passport::tokensCan() + Passport::setDefaultScope(); if you need per-user scope narrowing, override the scope repository with Passport::useClientRepository()-style bindings or bind your own AuthorizationServer.

Middleware as the resource-server seam. Wrap ResourceServer::validateAuthenticatedRequest() once and never call it from controllers:

try {
    $psr = $this->resourceServer->validateAuthenticatedRequest($this->toPsr7($request));
} catch (OAuthServerException $e) {
    return response()->json($e->getPayload(), $e->getHttpStatusCode())
        ->header('WWW-Authenticate', 'Bearer');
}

$request->attributes->set('oauth_user_id', $psr->getAttribute('oauth_user_id'));
$request->attributes->set('oauth_client_id', $psr->getAttribute('oauth_client_id'));
$request->attributes->set('oauth_scopes', $psr->getAttribute('oauth_scopes'));
Auth::setUser(User::findOrFail($psr->getAttribute('oauth_user_id'))); // if you want the guard populated

Custom grant = GrantTypeInterface + a container rebind. Implement getIdentifier(), canRespondToAccessTokenRequest(), respondToAccessTokenRequest() (extending AbstractGrant saves you ~80% of the work), then in a service provider resolve AuthorizationServer from the container and call enableGrantType(). In Passport this means decorating the singleton after PassportServiceProvider registers it — an AppServiceProvider::boot() with $this->app->extend(AuthorizationServer::class, ...) or $this->app->resolving(...).

Testing pattern that actually holds up. For Passport: Passport::actingAs(User::factory()->create(), ['orders:read']) — no HTTP round trip, scopes respected. For expiry and rotation behaviour use time travel ($this->travel(16)->minutes) rather than mocking the clock deep in the library. For raw-library repos, unit-test them in isolation against the library's own PSR-7 requests; those repositories are where 90% of your bugs will live (wrong null returns, missing revocation checks).

Deployment. Keys belong in your secret manager, not the repo. Use Passport::loadKeysFrom() / Passport::keyPath() or a CryptKey built from file_get_contents() with $keyPermissionsCheck = false. Run passport:keys (or your own copy step) as part of the deploy, before config:cache.

Gotchas and Tips

PSR-7 is the boundary and it leaks. Laravel requests aren't PSR-7; you need symfony/psr-http-message-bridge (Passport pulls in a PSR-7 implementation for you). The bigger trap: every OAuthServerException must be converted with generateHttpResponse() or getPayload(). If one escapes to Laravel's handler you get a 500 with a stack trace instead of a spec-shaped {"error":"invalid_grant"} — and OAuth clients fail in confusing ways.

The encryption key is load-bearing and silent. Lose or rotate it and every refresh token and auth code fails to decrypt, which surfaces as invalid_grant/400 with no obvious cause. It must be byte-identical across all app servers. Same for the public key on resource servers.

Key permission checks will bite you in Docker. CryptKey refuses keys with group/world read, which is usually right and always annoying in containers running as a different user. Pass false for the third constructor arg (or Passport::loadKeysFrom() after chmod 600) rather than fighting ownership. Note that v9.3 added sensitive-parameter masking on exceptions — older versions could leak key material in a LogicException message.

Revocation is a database hit on every API request. ResourceServer calls isAccessTokenRevoked() per request. Index oauth_access_tokens.id, keep access token TTLs short (15 min is a fine default), and resist the urge to make refresh tokens live for a year — Passport's default is 1 year and it's the most common misconfiguration I see.

Public clients and validateClient() nullability. Confidential clients pass a secret; public clients pass null. In v9, secrets are only validated for confidential clients on refresh, device and password grants. If you store hashed client secrets (Passport::hashClientSecrets() in newer versions), your repository must use Hash::check — a === comparison silently rejects everything after that change.

PKCE downgrade protection changes client behaviour. Since 8.4.0, a token request carrying a code_verifier without a matching code_challenge is rejected. Clients that "always send a verifier" start failing. AuthCodeGrant exposes setRequireCodeChallengeForPublicClients() — leave enforcement on for public clients, and test the SPA flow explicitly after any upgrade.

Redirect URIs are exact-match strings. Trailing slash, http vs https, localhost vs 127.0.0.1 — all distinct. v9 also rejects URIs with fragments. Custom schemes (myapp://callback) work since 8.3.4 but still need registering.

state is round-tripped, not validated. The server echoes it back; validating it against the user's session is your client's job. Also note the 9.0.1 fix: state was previously missing from invalid_scope and access_denied responses on some grants — if you wrote workarounds, remove them.

Error shapes changed in v9. The message property is gone from error responses (only error_description per spec), and expired/revoked/mismatched refresh tokens now correctly return invalid_grant with HTTP 400 instead of invalid_request with 401. Any client code branching on status code or message will break on upgrade. Pin the major version — Passport pins it for you, which is one more reason to go through Passport.

The JWT aud claim is the client identifier. That means you can't use audience to distinguish two different APIs from the same issuer. Filter on oauth_client_id, or add a custom claim by overriding convertToJWT() on your access token entity — keeping the standard claims intact.

PSR-14 events won't reach Laravel listeners by default. The server uses its own emitter unless you pass a dispatcher (since 9.0.1 it's persisted rather than regenerated per request). Laravel's dispatcher isn't PSR-14, so bridging it needs an adapter. If you want RequestAccessTokenEvent/RequestRefreshTokenEvent/failed-client-auth telemetry, wire that explicitly — it's the cleanest observability hook the library offers, and it's easy to assume it "just works" in a Laravel app and be wrong.

Refresh token rotation semantics. The default is to revoke the old refresh token on use; revokeRefreshTokens(false) keeps it alive, and 9.0.1 fixed a bug where that flag also disabled issuing new refresh tokens. If you support long-lived mobile sessions, decide deliberately and document it — clients that retry a rotated token look like a token-theft attack to your logs.

Debugging checklist, in order: (1) is the Authorization: Bearer header arriving intact through nginx/Cloudflare? (2) does the public key match the private key that signed the token? (3) is the token expired or not-yet-valid (clock drift — use JWT leeway across load-balanced hosts)? (4) is the refresh token's client id the same as the requesting client? The library returns deliberately vague invalid_grant errors, so instrument RequestEvent rather than guessing.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource