PHPackages                             locksyk/api-session-bundle - 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. locksyk/api-session-bundle

ActiveSymfony-bundle[Authentication &amp; Authorization](/categories/authentication)

locksyk/api-session-bundle
==========================

Stateful Symfony firewalls for API endpoints: the session id travels in the Authorization Bearer header instead of a cookie, with an explicit switch-user endpoint.

v0.1.0(today)00GPL-2.0-onlyPHPPHP &gt;=8.2CI passing

Since Jul 29Pushed todayCompare

[ Source](https://github.com/LocksyK/api-session-bundle)[ Packagist](https://packagist.org/packages/locksyk/api-session-bundle)[ Docs](https://github.com/LocksyK/api-session-bundle)[ RSS](/packages/locksyk-api-session-bundle/feed)WikiDiscussions main Synced today

READMEChangelogDependencies (17)Versions (2)Used By (0)

ApiSessionBundle
================

[](#apisessionbundle)

Stateful Symfony firewalls for API endpoints: the session id travels in the `Authorization: Bearer` header instead of a cookie.

> **Status: pre-release.** Feature-complete against its design ([`DESIGN.md`](DESIGN.md)) and fully tested, but not yet tagged or published to Packagist.

Why
---

[](#why)

Stateless token schemes (JWT &amp; friends) give up what server-side sessions provide for free: instant revocation, server-side state, idle expiry, no claims-refresh problem. This bundle keeps Symfony's ordinary session machinery - it just moves the session id out of the cookie and into a bearer token:

- **Bearer-token sessions** - no `Set-Cookie` on API responses, ever; session cookies sent by clients are ignored. The token is handed to the client at login and presented as `Authorization: Bearer `.
- **Pre-authentication sessions work** - a session created before login (a WebAuthn challenge, OIDC state, a CSRF-protected form) gets a token too, and login rotates it (fixation protection included).
- **Explicit switch-user endpoints** - impersonation only exists on routes the app mounts; the generic `_switch_user` parameter never works.
- **Coexistence** - tokens the bundle doesn't recognise (OIDC access tokens, HTTP Basic, API keys) pass through untouched to the rest of the authenticator chain on the same firewall.
- **Optional absolute lifetime + refresh** - expiry is embedded and signed in the token itself; no server-side bookkeeping to corrupt.

Requirements: PHP ≥ 8.2, Symfony 6.4 LTS or 7.x.

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

[](#installation)

```
composer require locksyk/api-session-bundle
```

Enable the bundle (Flex does this automatically):

```
// config/bundles.php
LocksyK\ApiSessionBundle\ApiSessionBundle::class => ['all' => true],
```

Quick start
-----------

[](#quick-start)

Keep your firewall exactly as it would be for cookie sessions - any authenticator works (`json_login`, WebAuthn, OIDC, custom):

```
# config/packages/security.yaml
security:
    firewalls:
        api:
            pattern: ^/api
            provider: app_users
            json_login: { check_path: api_login }
            logout: { path: api_logout }
```

Then bridge it:

```
# config/packages/api_session.yaml
api_session:
    firewalls: [api]
    logout: { json_response: true }   # 204 instead of a redirect
```

Optionally mount the ready-made login responder (the authenticator still handles credentials; this shapes the success response):

```
# config/routes.yaml
api_login:
    path: /api/login
    methods: POST
    controller: LocksyK\ApiSessionBundle\Controller\LoginController
api_logout:
    path: /api/logout
    methods: POST
```

That's it. Log in and the response carries the session token in the `X-Session-Token` header (and, with `LoginController`, in the body as `{user, roles, token}`); present it on every request:

```
Authorization: Bearer sess_k3f9...a1.Zm9v...bar

```

The client contract
-------------------

[](#the-client-contract)

- Whenever a response carries the token header (`X-Session-Token` by default), it announces the **current** token - store it and use it from then on. In practice the token changes at anonymous-session creation and at login; a refresh (below) mints an *additional* token.
- A `401` carrying `WWW-Authenticate: Bearer error="invalid_token"`(RFC 6750) means the presented token was genuinely one of ours but is no longer usable - expired or its session is gone. Discard it and re-authenticate. A plain `401` without that header means no (valid) credentials were presented at all.
- No cookies are involved in either direction.

Pre-authentication flows work naturally: e.g. a WebAuthn ceremony calls `POST /api/auth/options` (server stores the challenge in a fresh anonymous session → response carries its token), then presents that token on `POST /api/auth/verify`; the login success response carries the rotated, authenticated token.

Configuration reference
-----------------------

[](#configuration-reference)

```
api_session:
    firewalls: []                  # firewall names to bridge (required)
    token:
        response_header: X-Session-Token   # null to disable the header
        prefix: sess_              # token prefix; non-empty, dot-free
        lifetime: null             # seconds; non-null enables refresh
    csrf_bypass: true              # CSRF check bypass on bridged firewalls
    stale_token:
        www_authenticate: true     # RFC 6750 invalid_token on 401s
    logout:
        json_response: false       # opt-in: 204 on logout
    switch_user:
        role: ROLE_ALLOWED_TO_SWITCH
```

Token format
------------

[](#token-format)

The default strategy signs the claims with HMAC-SHA256, keyed by a derivation of `kernel.secret` (rotating the secret invalidates all outstanding tokens):

```
without lifetime: .
with lifetime:    ..

```

The MAC covers the expiry, so clients may read it (to refresh proactively) but cannot alter it. A leaked session-store key is not a usable credential - building a token requires the app secret.

Absolute lifetime and refresh
-----------------------------

[](#absolute-lifetime-and-refresh)

By default tokens live as long as their session (sliding idle expiry). Setting `token.lifetime` embeds a signed absolute expiry in every minted token, enforced *before the session is ever touched*. Mount the refresh endpoint to let clients extend their stay:

```
api_refresh:
    path: /api/refresh
    methods: POST
    controller: LocksyK\ApiSessionBundle\Controller\RefreshTokenController
```

`POST /api/refresh` with a valid token returns `{token, expires_in}` - an **additional** token for the same session with a fresh expiry. Notes:

- Refresh does **not** revoke the previous token; it remains valid until its own expiry. Revocation is what logout/session invalidation is for: the session dies, killing every outstanding token at once. (See "Enhancement patterns" for stricter semantics.)
- Concurrent refreshes from racing client threads are harmless.
- Sessions have no built-in cap on total age under active refresh.

Logout
------

[](#logout)

Symfony's plain `logout:` firewall config works as-is - the session is invalidated, so every token for it dies instantly, and no stray cookie headers leak. `logout.json_response: true` answers `204 No Content`instead of the default redirect; your own `LogoutEvent` listeners can still replace the response.

Impersonation (switch-user)
---------------------------

[](#impersonation-switch-user)

Do **not** enable Symfony's `switch_user` firewall option. Mount the endpoints instead - nothing impersonation-related exists unless you do:

```
api_impersonate:
    path: /api/impersonate
    methods: POST
    controller: LocksyK\ApiSessionBundle\Controller\EnterImpersonationController
api_impersonate_exit:
    path: /api/impersonate
    methods: DELETE
    controller: LocksyK\ApiSessionBundle\Controller\ExitImpersonationController
```

`POST` with `{"identifier": ""}` enters impersonation (requires `switch_user.role`; the impersonation token carries the target's roles plus `ROLE_PREVIOUS_ADMIN`, so `IS_IMPERSONATOR` and friends behave as usual); `DELETE` exits. Responses: `400` bad body, `403` missing role or listener veto, `404` unknown target, `409` already/not impersonating.

Guard listeners subscribe to the bundle's `ImpersonationEnteredEvent` / `ImpersonationExitedEvent` - each fires only for its direction, so no request-parameter sniffing - and veto by throwing `AccessDeniedException`. Symfony's `SwitchUserEvent` is also dispatched for existing listeners. The target is resolved through your user provider (the autowiring alias when exactly one is configured; multi-provider apps rebind the controller's provider argument).

Stale-token signalling
----------------------

[](#stale-token-signalling)

When a presented token verifies as ours but is no longer usable, three signals fire:

- request attribute `StaleTokenDetector::STALE_ATTRIBUTE` - computed before your entry point runs, so it can distinguish "session expired, log in again" from "no credentials";
- `WWW-Authenticate: Bearer error="invalid_token"` appended to the 401 (disable with `stale_token.www_authenticate: false`);
- a `StaleSessionTokenEvent` for telemetry, once per request.

CSRF
----

[](#csrf)

Bearer-carried sessions have no ambient credential, so classic CSRF does not apply; by default the bundle bypasses CSRF validation on bridged firewalls (form-login, logout, and app-level checks alike) while leaving the rest of the app untouched. Set `csrf_bypass: false`to keep validation.

Custom token strategies
-----------------------

[](#custom-token-strategies)

Alias `LocksyK\ApiSessionBundle\Token\TokenStrategyInterface` to your own service to change the token encoding - e.g. authenticated encryption that hides the session id from the client entirely:

```
interface TokenStrategyInterface
{
    public function mint(string $sessionId, ?int $expiresAt = null): string;
    public function extract(string $bearerValue): ?SessionToken;
}
```

**Security contract:** every claim - session id *and* expiry - must be integrity-protected. The bridge enforces expiry from the decoded claims alone; an encoding that lets a client alter them without detection is exploitable by construction, and that responsibility sits with the implementation. Strategies must be able to mint for any session id the session layer chooses (non-invertible mappings are unsupported).

Enhancement patterns: revocation
--------------------------------

[](#enhancement-patterns-revocation)

Two events form the extension surface for stricter token semantics:

- `VerifySessionTokenEvent` - dispatched before the firewall for a presented token whose session is live; call `revoke(?reason)` to reject it into the stale-token signalling. The session record is preserved (newer tokens may share it); invalidate it yourself for a full kill. Dispatched only when listened to - registering a listener makes the bridge start sessions eagerly on token-presenting requests.
- `SessionTokenMintedEvent` - every mint, with the new token's claims and a reason (`anonymous` / `login` / `refresh`), for stamping.

Reference implementations (copy them from the test suite; the stamped fields are ordinary app-owned session data):

- **Early revocation after refresh** - `tests/Functional/Fixture/RevokeSupersededTokensListener.php`: superseded tokens die a grace period after a refresh instead of living to their own expiry.
- **Hard session-age cap** - `tests/Functional/Fixture/SessionAgeCapListener.php`: the session dies N seconds after login no matter how often it refreshes; the pre-firewall guard means refresh can't extend past the cap.
- **Mint-policy tripwire** - `tests/Functional/Fixture/UnexpectedTokenMintListener.php`: an app that returns tokens in JSON bodies on known endpoints 500s if a token is ever minted on any other route, catching stray session creation (e.g. a per-request authenticator persisting a session on an arbitrary route) before clients silently lose tokens they never saw. Encodes the allowlist gotchas: login mints twice, logout's response advertises a fresh anonymous token, stale-token 401s mint replacements anywhere (skipped via the stale attribute), and the throw happens once per request so the error response can render.

License
-------

[](#license)

GNU General Public License, version 2 only (GPL-2.0-only). See [LICENSE](LICENSE).

###  Health Score

36

—

LowBetter than 79% of packages

Maintenance100

Actively maintained with recent releases

Popularity0

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity35

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.

###  Release Activity

Cadence

Unknown

Total

1

Last Release

0d ago

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/24613?v=4)[Kris Shannon](/maintainers/KrisShannon)[@KrisShannon](https://github.com/KrisShannon)

---

Top Contributors

[![KrisShannon](https://avatars.githubusercontent.com/u/24613?v=4)](https://github.com/KrisShannon "KrisShannon (2 commits)")

---

Tags

apisymfonybundleAuthenticationsessionswitch-userbearer token

###  Code Quality

TestsPHPUnit

Static AnalysisPHPStan

Code StylePHP CS Fixer

Type Coverage Yes

### Embed Badge

![Health badge](/badges/locksyk-api-session-bundle/health.svg)

```
[![Health](https://phpackages.com/badges/locksyk-api-session-bundle/health.svg)](https://phpackages.com/packages/locksyk-api-session-bundle)
```

###  Alternatives

[symfony/security-bundle

Provides a tight integration of the Security component into the Symfony full-stack framework

2.5k185.6M2.4k](/packages/symfony-security-bundle)[easycorp/easyadmin-bundle

Admin generator for Symfony applications

4.3k17.9M402](/packages/easycorp-easyadmin-bundle)[sulu/sulu

Core framework that implements the functionality of the Sulu content management system

1.3k1.4M215](/packages/sulu-sulu)[open-dxp/opendxp

Content &amp; Product Management Framework (CMS/PIM)

9421.6k64](/packages/open-dxp-opendxp)[contao-community-alliance/dc-general

Universal data container for Contao

1680.8k92](/packages/contao-community-alliance-dc-general)[oro/platform

Business Application Platform (BAP)

645143.5k116](/packages/oro-platform)

PHPackages © 2026

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