PHPackages                             sinemacula/laravel-authentication - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Authentication &amp; Authorization](/categories/authentication)
4. /
5. sinemacula/laravel-authentication

ActiveLibrary[Authentication &amp; Authorization](/categories/authentication)

sinemacula/laravel-authentication
=================================

Stateless contextual authentication primitives (Identity, Principal, Device, Tenant) for Laravel 12 and 13.

00PHPCI passing

Since Jun 25Pushed 1mo agoCompare

[ Source](https://github.com/sinemacula/laravel-authentication)[ Packagist](https://packagist.org/packages/sinemacula/laravel-authentication)[ RSS](/packages/sinemacula-laravel-authentication/feed)WikiDiscussions master Synced 3w ago

READMEChangelogDependenciesVersions (2)Used By (0)

Laravel Authentication
======================

[](#laravel-authentication)

[![Latest Stable Version](https://camo.githubusercontent.com/4d6399151d1e74f1902c4e7bddb160138fbbe4b78beb737f71ee3a4a941a4dd7/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f73696e656d6163756c612f6c61726176656c2d61757468656e7469636174696f6e2e737667)](https://packagist.org/packages/sinemacula/laravel-authentication)[![Build Status](https://github.com/sinemacula/laravel-authentication/actions/workflows/tests.yml/badge.svg?branch=master)](https://github.com/sinemacula/laravel-authentication/actions/workflows/tests.yml)[![Maintainability](https://camo.githubusercontent.com/1f3b37262292849801e25def975ab8fd331d2c0bd61cada65b02152c13654391/68747470733a2f2f716c74792e73682f67682f73696e656d6163756c612f70726f6a656374732f6c61726176656c2d61757468656e7469636174696f6e2f6d61696e7461696e6162696c6974792e737667)](https://qlty.sh/gh/sinemacula/projects/laravel-authentication)[![Code Coverage](https://camo.githubusercontent.com/bb55b0a443f91ba02399b543e766621d7aeac77ed5f1993ee729126abd0bdb77/68747470733a2f2f716c74792e73682f67682f73696e656d6163756c612f70726f6a656374732f6c61726176656c2d61757468656e7469636174696f6e2f636f7665726167652e737667)](https://qlty.sh/gh/sinemacula/projects/laravel-authentication)[![Total Downloads](https://camo.githubusercontent.com/0e7ec007137ba6e7e6fcd69e0e32194ed5982855b5e20fe238f089674d2d4748/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f64742f73696e656d6163756c612f6c61726176656c2d61757468656e7469636174696f6e2e737667)](https://packagist.org/packages/sinemacula/laravel-authentication)

Stateless contextual authentication for Laravel. Distinguishes the authenticated **Identity** from the acting **Principal** and the issuing **Device**, exposed through Laravel's standard `Auth` facade, middleware, and events.

Drops into Laravel's existing auth machinery - same `Auth::check()`, `Auth::user()`, `auth()->guard('api')`, same middleware, same events. Adds contextual accessors (`Auth::identity()`, `Auth::principal()`, `Auth::device()`) and ships hardened JWT and HTTP Basic guards.

Core Concept
------------

[](#core-concept)

Most auth packages collapse "who you are" and "who you're acting as" into a single user. This package separates them:

ConceptWhat it is**Identity**Who logged in. The account behind the request - a person or a service account.**Principal**Who the request is acting as. Often the same as the identity; can be a role or membership.**Device**Which client obtained the login - a browser, a mobile app, a CLI session.**Tenant**The isolation boundary the principal acts within. Optional, for multi-tenant apps.**Type**An optional categorical label on the tenant - e.g. `"staff"` vs `"customer"`.Both **2D** (identity-is-principal) and **3D** (identity → separate principal → tenant) adoption modes are supported by the same guards. Start 2D, grow into 3D without re-platforming.

Access tokens are self-verifying JWTs with no server-side store. Refresh is stateful - the rotation digest and device record live in the `devices` table so replay and stale credentials can be detected server-side.

Features
--------

[](#features)

- **Two guards**, both sessionless: `jwt` (Bearer token) and `basic` (HTTP Basic).
- **Contextual accessors** on the standard `Auth` facade: `identity()`, `principal()`, `device()`, `tenant()`, `type()`.
- **Hardened JWT pipeline**: strict `iss` / `aud` / `typ` / `exp` / leeway enforcement, per-token `jti`, and fail-closed behaviour on empty secrets, unsupported algorithms, type confusion, and `pid` / `did` mismatches.
- **Refresh-token rotation** with constant-time digest verification, atomic per-device rotation, and machine-readable `RefreshFailed` events for SIEM attribution.
- **Kid-based key rotation** - issue under one kid, verify against a `kid → secret` map.
- **Device tracking** with debounced `last_logged_in_at` writes.
- **First-class events** - Laravel's standard `Attempting` / `Validated` / `Authenticated` / `Login` / `Failed`alongside custom `PrincipalAssigned` / `DeviceAuthenticated` / `Refreshed` / `RefreshFailed`.
- **Pluggable everywhere**: identity model, device model, principal resolver, identifier field, table names.

Design Notes
------------

[](#design-notes)

Adoption-focused quick-starts are below. Maintainer-oriented security and lifecycle contracts live in `docs/design/`:

- `docs/design/guard-lifecycle-and-events.md`
- `docs/design/refresh-rotation-and-replay.md`
- `docs/design/fail-closed-pid-did.md`
- `docs/design/access-only-mode.md`

Installation
------------

[](#installation)

```
composer require sinemacula/laravel-authentication
```

Publish the config and device migration, then migrate:

```
php artisan vendor:publish --tag=authentication-config
php artisan vendor:publish --tag=authentication-migrations
php artisan migrate
```

Set the JWT secret:

```
AUTHENTICATION_JWT_SECRET="a-strong-random-value-of-at-least-32-bytes"
```

The package fails closed when a JWT guard or `Auth::jwt(...)` service is resolved with empty or invalid signing material.

### Access-only mode (no devices, no refresh)

[](#access-only-mode-no-devices-no-refresh)

Device tracking and refresh are opt-in. For M2M APIs, simple backends, or short-lived session flows:

1. Skip the `authentication-migrations` publish.
2. Do not implement `HasDevices` on your identity model.
3. Issue access tokens with `Auth::jwt('api')->issueAccessToken($identity, $principal, null)`.
4. Do not call `$guard->refresh(...)`. Clients re-authenticate when the access token expires.

`Auth::device()` returns `null` in this mode; the rest of the contextual surface works normally. See `docs/design/access-only-mode.md`.

Configuration
-------------

[](#configuration)

### Guards and providers

[](#guards-and-providers)

Register guards and providers in `config/auth.php` exactly as you would with any first-party guard:

```
'guards' => [
    'api' => [
        'driver'   => 'jwt',
        'provider' => 'users',
    ],
    'cli' => [
        'driver'   => 'basic',
        'provider' => 'users',
    ],
],

'providers' => [
    'users' => [
        'driver' => 'model',
        'model'  => App\Models\User::class,
    ],
],
```

### Per-guard JWT configuration

[](#per-guard-jwt-configuration)

Every JWT guard inherits its signing material, audience, issuer, TTLs, and leeway from the package-wide `authentication.jwt.*` defaults. Any guard may override them with a `jwt` sub-block on its `config/auth.php`entry; missing fields fall back to the defaults:

```
'guards' => [
    'staff' => [
        'driver'   => 'jwt',
        'provider' => 'users',
        'jwt'      => [
            'secret'   => env('STAFF_JWT_SECRET'),
            'audience' => 'staff-api',
        ],
    ],
    'customer' => [
        'driver'   => 'jwt',
        'provider' => 'users',
        'jwt'      => [
            'secret'   => env('CUSTOMER_JWT_SECRET'),
            'audience' => 'customer-api',
        ],
    ],
],
```

Routes opt into a boundary via `auth:staff` or `auth:customer` middleware. The `aud` claim on each issued token matches the guard that issued it, so tokens minted for one audience cannot authenticate against the other. Every field in the package `jwt` block is overridable (`secret`, `keys`, `active_kid`, `algorithm`, `access_ttl_minutes`, `refresh_ttl_minutes`, `leeway_seconds`, `issuer`, `audience`).

### Per-guard basic-auth identifier field

[](#per-guard-basic-auth-identifier-field)

The `basic` driver accepts an `identifier_field` override - e.g. an `email`-keyed web guard alongside a `key_id`-keyed tenant API guard:

```
'guards' => [
    'cli' => [
        'driver'   => 'basic',
        'provider' => 'users',
    ],
    'tenant_api' => [
        'driver'           => 'basic',
        'provider'         => 'tenant_api_keys',
        'identifier_field' => 'key_id',
    ],
],

'providers' => [
    'tenant_api_keys' => [
        'driver' => 'model',
        'model'  => App\Models\TenantApiKey::class,
    ],
],
```

Omitting `identifier_field` falls back to `authentication.credentials.identifier_field` (default `email`).

### Per-guard principal resolvers

[](#per-guard-principal-resolvers)

By default, every guard uses the app-wide `PrincipalResolver` binding. Any guard may override it locally:

```
'guards' => [
    'staff' => [
        'driver'             => 'jwt',
        'provider'           => 'users',
        'principal_resolver' => App\Auth\Resolvers\StaffPrincipalResolver::class,
    ],
],
```

Precedence: guard-local `principal_resolver` → app-wide `PrincipalResolver::class` binding → package default `DefaultPrincipalResolver`. The same resolver is used on bearer-token and refresh paths.

### Device

[](#device)

The published `authentication.php` config controls the device model, table, and last-seen debounce:

```
'device' => [
    'model'                      => SineMacula\Laravel\Authentication\Models\Device::class,
    'table'                      => 'devices',
    'refresh_key_column'         => 'refresh_key',
    'last_seen_throttle_seconds' => 60,
],
```

The shipped `Device` model uses UUID v7 primary keys and a polymorphic `authenticatable` relation. Subclass it or swap `device.model` entirely - custom models must implement the `EloquentDevice` contract.

### Credential validation timing

[](#credential-validation-timing)

The `basic` guard wraps credential checks in a constant-time `Timebox` to prevent timing side-channels:

```
'timebox' => [
    'credentials_microseconds' => 400000,
],
```

Must exceed the worst-case hasher cost.

### Optional bearer identity cache

[](#optional-bearer-identity-cache)

Cross-request resolution caching is off by default. When enabled, it applies only to JWT bearer identity rehydration through model providers - basic-auth lookups, device lookups, principal resolution, and the refresh path stay live.

```
'resolution_cache' => [
    'store' => env('AUTHENTICATION_RESOLUTION_CACHE_STORE'),
    'jwt'   => [
        'identity_ttl_seconds'  => env('AUTHENTICATION_JWT_IDENTITY_CACHE_TTL_SECONDS', 0),
        'principal_ttl_seconds' => env('AUTHENTICATION_JWT_PRINCIPAL_CACHE_TTL_SECONDS', 0),
    ],
],
```

- `identity_ttl_seconds = 0` disables the cache.
- `principal_ttl_seconds` is reserved for future use; leave at `0`.
- Active-state checks, `pid` matching, and `did` device validation still run live on every request.

If you opt in, wire explicit invalidation from your identity model observer:

```
use App\Models\User;
use SineMacula\Laravel\Authentication\Cache\ResolutionCacheInvalidator;

final class UserObserver
{
    public function saved(User $user): void
    {
        app(ResolutionCacheInvalidator::class)->forgetIdentity($user);
    }

    public function deleted(User $user): void
    {
        app(ResolutionCacheInvalidator::class)->forgetIdentity($user);
    }
}
```

Do not enable the cache unless that invalidation wiring is in place. If the auth identifier itself changes, pass the previous identifier to `forgetIdentity()` as well.

Identity Models
---------------

[](#identity-models)

### 2D adoption (identity is the principal)

[](#2d-adoption-identity-is-the-principal)

One model implements both `Identity` and `Principal` - the user who logs in *is* the acting principal:

```
use Illuminate\Foundation\Auth\User;
use SineMacula\Laravel\Authentication\Contracts\Identity;
use SineMacula\Laravel\Authentication\Contracts\Principal;
use SineMacula\Laravel\Authentication\Traits\ActsAsPrincipal;
use SineMacula\Laravel\Authentication\Traits\Authenticatable;

class AppUser extends User implements Identity, Principal
{
    use Authenticatable, ActsAsPrincipal;
}
```

Point `auth.providers.users.model` at `AppUser::class`. `Auth::identity()` and `Auth::principal()` both return the same instance.

Add `HasDevices` for device tracking and refresh-token rotation:

```
use Illuminate\Database\Eloquent\Relations\MorphMany;
use SineMacula\Laravel\Authentication\Contracts\HasDevices;
use SineMacula\Laravel\Authentication\Models\Device;

class AppUser extends User implements Identity, Principal, HasDevices
{
    use Authenticatable, ActsAsPrincipal;

    public function devices(): MorphMany
    {
        return $this->morphMany(Device::class, 'authenticatable');
    }
}
```

### 3D adoption (separate identity, principal, tenant)

[](#3d-adoption-separate-identity-principal-tenant)

For multi-tenant apps where the logged-in human acts on behalf of a tenant-scoped actor, split identity and principal into two models. The identity implements `HasPrincipals` and returns its own principals query:

```
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Foundation\Auth\User;
use SineMacula\Laravel\Authentication\Contracts\HasDevices;
use SineMacula\Laravel\Authentication\Contracts\HasPrincipals;
use SineMacula\Laravel\Authentication\Contracts\HasType;
use SineMacula\Laravel\Authentication\Contracts\Identity;
use SineMacula\Laravel\Authentication\Contracts\Principal as PrincipalContract;
use SineMacula\Laravel\Authentication\Contracts\Tenant as TenantContract;
use SineMacula\Laravel\Authentication\Models\Device;
use SineMacula\Laravel\Authentication\Traits\ActsAsPrincipal;
use SineMacula\Laravel\Authentication\Traits\ActsAsTenant;
use SineMacula\Laravel\Authentication\Traits\Authenticatable;
use SineMacula\Laravel\Authentication\Traits\ProvidesTenantType;

class AppIdentity extends User implements Identity, HasDevices, HasPrincipals
{
    use Authenticatable;

    public function principals(): HasMany
    {
        return $this->hasMany(AppMembership::class, 'identity_id');
    }

    public function devices(): MorphMany
    {
        return $this->morphMany(Device::class, 'authenticatable');
    }

    public function resolveDefaultPrincipal(): ?PrincipalContract
    {
        return $this->principals()->where('is_active', true)->first();
    }
}

class AppMembership extends Model implements PrincipalContract
{
    use ActsAsPrincipal;

    protected $fillable = ['identity_id', 'tenant_id', 'role', 'is_active'];

    public function tenant(): BelongsTo
    {
        return $this->belongsTo(AppTenant::class);
    }
}

class AppTenant extends Model implements HasType, TenantContract
{
    use ActsAsTenant, ProvidesTenantType;
}
```

With that shape:

- `Auth::identity()` returns the `AppIdentity`.
- `Auth::principal()` returns the `AppMembership` - resolved via `principals()->find($pid)` when the JWT carries a `pid`, or via `resolveDefaultPrincipal()` otherwise.
- `Auth::tenant()` returns the `AppTenant` the membership belongs to.
- `Auth::type()` returns the tenant's type string, or `null` when `HasType` is absent.

Implement `ResolvesHintedPrincipal` on the identity to replace the default `principals()->find($hint)` lookup with a custom query - for example, joining the tenant row in one SQL so `Auth::tenant()` does not lazy-load.

For domain-specific resolution (subdomain, header, session claim), implement `PrincipalResolver` and bind it in a service provider:

```
$this->app->singleton(PrincipalResolver::class, MyTenantScopedResolver::class);
```

### Active-state enforcement

[](#active-state-enforcement)

When an identity model implements `CanBeActive`, both guards consult `isActive()` on every bearer and credential path and reject authentication when it returns `false`:

```
use SineMacula\Laravel\Authentication\Contracts\CanBeActive;

class AppUser extends User implements Identity, Principal, CanBeActive
{
    use Authenticatable, ActsAsPrincipal;

    public function isActive(): bool
    {
        return $this->suspended_at === null;
    }
}
```

### HTTP Basic behind PHP-FPM / nginx

[](#http-basic-behind-php-fpm--nginx)

The `basic` guard reads credentials via `Request::getUser()` / `Request::getPassword()`, which pull from `$_SERVER['PHP_AUTH_USER']` / `PHP_AUTH_PW`. Behind **PHP-FPM + nginx**, the `Authorization` header is **not**forwarded into those superglobals by default - forward it explicitly:

```
location ~ \.php$ {
    fastcgi_pass_header Authorization;
    # ...rest of the fastcgi block
}
```

Apache with `mod_php` populates these variables automatically.

Usage
-----

[](#usage)

Contextual accessors on the standard `Auth` facade:

```
use SineMacula\Laravel\Authentication\Facades\Auth;

Auth::check();          // bool - same as Laravel
Auth::user();           // Identity|null - same as Laravel

Auth::identity();       // Identity|null   - the authenticated subject
Auth::principal();      // Principal|null  - the acting principal
Auth::device();         // Device|null     - the issuing device
Auth::tenant();         // Tenant|null     - the tenant the principal acts within
Auth::type();           // string|null     - the tenant's type when HasType is declared
```

Issue tokens through the guard-scoped JWT service, then use the guard for refresh:

```
$tokens = Auth::jwt('api');
$guard  = auth()->guard('api');

$accessToken = $tokens->issueAccessToken($identity, $principal, $device);

$rotated = $guard->refresh($refreshToken);

if ($rotated === null) {
    // RefreshFailed event already dispatched with a machine-readable reason
    abort(401);
}

return [
    'access_token'  => $rotated->accessToken,
    'refresh_token' => $rotated->refreshToken,
];
```

Key Rotation
------------

[](#key-rotation)

For graceful signing-key rotation, configure `jwt.keys` and `jwt.active_kid`:

```
'jwt' => [
    'keys' => [
        '2026-04' => env('AUTHENTICATION_JWT_KEY_2026_04'),
        '2026-03' => env('AUTHENTICATION_JWT_KEY_2026_03'),
    ],
    'active_kid' => env('AUTHENTICATION_JWT_ACTIVE_KID', '2026-04'),
],
```

New tokens are signed with the active kid and carry it in the JWT header. The verifier accepts any kid in the map - add a new kid, point `active_kid` at it, retire the old kid once every token signed under it has expired.

Events
------

[](#events)

EventFired when`Illuminate\Auth\Events\Attempting`Bearer, refresh, or credential attempt starts`Illuminate\Auth\Events\Validated`A successful `login()` path is about to bind context`Illuminate\Auth\Events\Authenticated`Identity bound to the guard`Illuminate\Auth\Events\Login`Full lifecycle complete`Illuminate\Auth\Events\Failed`Any bearer, refresh, or credential rejection`SineMacula\...\Events\PrincipalAssigned`Principal resolved and bound`SineMacula\...\Events\DeviceAuthenticated`Device hydrated and bound; listeners may persist metadata`SineMacula\...\Events\Refreshed`Refresh exchange completed`SineMacula\...\Events\RefreshFailed`Refresh exchange failed (carries machine-readable reason)`RefreshFailed` carries a `RefreshFailureReason` backed enum for SIEM attribution:

ReasonMeaning`token_invalid`Decode, expiry, typ, iss, or aud failure`device_unknown`Device id did not resolve`rotation_mismatch`Digest did not match the stored refresh key`rotation_reuse`Replay or concurrent rotation; device revoked`device_revoked`Device row marked revoked`authenticatable_missing`Device authenticatable relation missing`identity_inactive`Resolved identity reported inactive`principal_unresolved`Principal resolver returned null`principal_mismatch`Resolved principal does not match refresh token hint`principal_inactive`Resolved principal reported inactiveExtensibility
-------------

[](#extensibility)

All concrete classes in this package are `final`. Extension is through **composition and DI**, not inheritance:

Extension pointHowCustom identity / principal modelImplement `Identity`, `Principal` (and optional capability interfaces)Custom device modelImplement `EloquentDevice`, use the `ActsAsDevice` traitCustom principal resolutionImplement `PrincipalResolver`, bind per-guard or globallyCustom identity retrievalSubclass `ModelProvider` (the one non-final service)Guard-scoped JWT settingsOverride `jwt.*` keys in `auth.guards..jwt`Resolution cachingBind your own `ResolutionCache` implementation`AbstractGuard` is not final and may be extended for entirely new guard types.

Requirements
------------

[](#requirements)

- PHP ^8.3 (extensions: hash, mbstring, openssl)
- Laravel ^12.40 || ^13.3

Testing
-------

[](#testing)

```
composer test               # all suites in parallel (Paratest)
composer test:coverage      # all suites with clover coverage
composer test:unit          # unit suite only
composer test:feature       # feature suite only
composer test:integration   # integration suite only
composer test:performance   # performance budget suite (serial)
composer test:mutation      # scoped mutation gate
composer test:mutation:full # full mutation suite (no thresholds)
composer bench              # PHPBench hot-path benchmarks
composer check              # static analysis + style
```

Changelog
---------

[](#changelog)

See [CHANGELOG.md](CHANGELOG.md) for a list of notable changes.

Contributing
------------

[](#contributing)

Contributions are welcome. Please read [CONTRIBUTING.md](CONTRIBUTING.md)for guidelines on branching, commits, code quality, and pull requests.

Security
--------

[](#security)

If you discover a security vulnerability, please report it responsibly. See [SECURITY.md](SECURITY.md) for the disclosure policy and contact details.

License
-------

[](#license)

Licensed under the [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0).

###  Health Score

20

—

LowBetter than 12% of packages

Maintenance61

Regular maintenance activity

Popularity0

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity13

Early-stage or recently created project

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

### Community

Maintainers

![](https://www.gravatar.com/avatar/6262ea965c244b0c946a2f29a94da05e30846c066a0b59399466216654c78fe6?d=identicon)[sinemacula](/maintainers/sinemacula)

---

Top Contributors

[![sinemacula-ben](https://avatars.githubusercontent.com/u/118753672?v=4)](https://github.com/sinemacula-ben "sinemacula-ben (17 commits)")

### Embed Badge

![Health badge](/badges/sinemacula-laravel-authentication/health.svg)

```
[![Health](https://phpackages.com/badges/sinemacula-laravel-authentication/health.svg)](https://phpackages.com/packages/sinemacula-laravel-authentication)
```

###  Alternatives

[vitalybaev/laravel5-dkim

Laravel 5/6 package for signing outgoing messages with DKIM.

3163.1k](/packages/vitalybaev-laravel5-dkim)[firemultimedia/mautic-multi-captcha-bundle

This plugin brings Google's reCAPTCHA, hCaptcha, and Cloudflare Turnstile integration to mautic.

141.3k](/packages/firemultimedia-mautic-multi-captcha-bundle)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
