laravel-entra-auth maintained by visin
laravel-entra-auth
Microsoft Entra ID client-credentials auth for Laravel services — both halves in one tested implementation, so every service authenticates the same way:
- Resource server: validate incoming Entra JWTs — signature via tenant
JWKS, expiry, audience (both
api://<guid>and bare guid forms), tenant pinning, issuer, and app roles. Discovery and JWKS responses are cached, and tokens with unknown key ids trigger a throttled JWKS refresh for Entra key rotation. - Client: acquire app-only tokens with the client credentials flow, cached per scope until shortly before expiry.
Installation
composer require visin/laravel-entra-auth
The service provider is auto-discovered. Publish the config to override defaults:
php artisan vendor:publish --tag=entra-auth-config
Configuration
# Resource server (incoming token validation)
ENTRA_AUTH_TENANT_ID=<entra-tenant-guid>
ENTRA_AUTH_AUDIENCE=api://<this-api-app-registration-guid>
ENTRA_AUTH_CACHE_TTL=3600
ENTRA_AUTH_HTTP_TIMEOUT=5
ENTRA_AUTH_LEEWAY=60
ENTRA_AUTH_UNKNOWN_KEY_REFRESH_COOLDOWN=60
# Client (outgoing token acquisition), only needed when calling other APIs
ENTRA_AUTH_CLIENT_ID=<this-service-app-registration-guid>
ENTRA_AUTH_CLIENT_SECRET=<client-secret>
ENTRA_AUTH_SCOPE=api://<target-api-app-registration-guid>/.default
The protected API's app registration must issue v2 access tokens
("api": { "requestedAccessTokenVersion": 2 } in the app manifest).
Protecting routes
entra.auth validates the bearer token; entra.role:<role> requires an app
role from the token's roles claim:
Route::middleware(['entra.auth', 'entra.role:invoices.write'])
->post('/v1/invoices', [InvoiceController::class, 'store']);
The validated token is available on the request:
use Visin\EntraAuth\AccessToken;
use Visin\EntraAuth\Http\Middleware\AuthenticateEntraToken;
/** @var AccessToken $token */
$token = $request->attributes->get(AuthenticateEntraToken::REQUEST_ATTRIBUTE);
$token->tenantId; // issuing tenant guid
$token->clientId; // calling app registration guid
$token->roles; // app roles from the token
$token->claims; // full decoded claims (stdClass)
Services with their own caller mapping (client registries, audit logging) can skip the middleware and use the validator directly:
use Visin\EntraAuth\Contracts\AccessTokenValidator;
$accessToken = app(AccessTokenValidator::class)->validate($bearerToken);
To restrict which roles are passed through, set allowed_roles in
config/entra-auth.php.
Calling another Entra-protected API
use Illuminate\Support\Facades\Http;
use Visin\EntraAuth\Client\ClientCredentialsTokenProvider;
$token = app(ClientCredentialsTokenProvider::class)->token();
$response = Http::withToken($token)->post('https://api.example.com/v1/invoices', [...]);
Pass a scope to target a different API: ->token('api://other-api/.default').
Testing your app
Bind a fake validator instead of faking HTTP:
use Visin\EntraAuth\AccessToken;
use Visin\EntraAuth\Contracts\AccessTokenValidator;
$this->app->singleton(AccessTokenValidator::class, fn () => new class implements AccessTokenValidator {
public function validate(string $token): AccessToken
{
return new AccessToken('tenant', 'client-a', ['invoices.read'], new \stdClass);
}
});
Requirements
PHP 8.3+ · Laravel 12 or 13 · firebase/php-jwt 6.10+ or 7.x
License
MIT — see LICENSE.md. Contributions welcome, see CONTRIBUTING.md.