escapestudios/wsse-authentication-bundle
EscapeWSSEAuthenticationBundle provides simple, out-of-the-box WSSE authentication for Symfony applications. Secure stateless API firewalls via security.yml, with configurable token lifetime, custom date formats, and a console command to delete expired nonces.
Step 0 — confirm you're actually on Symfony. escapestudios/wsse-authentication-bundle is a Symfony bundle, not a Laravel package. There is no service provider, no facade, no config/ publish tag. Every integration point in the README (AppKernel::registerBundles(), app/config/security.yml, app/console, the wsse firewall type) is Symfony-specific and predates Symfony 4. composer require may succeed in a Laravel app, but nothing will ever register the listener — you'll have installed dead weight (and likely dragged in old symfony/* / doctrine/cache constraints that fight your Laravel version).
So the day-one decision for a Laravel dev is binary:
composer require escapestudios/wsse-authentication-bundle:^2.2, register the bundle, add 5 lines to security.yml. Done.Where to look first in the source. You're porting, so read in this order:
Security/Http/Firewall/Listener.php — parses the X-WSSE header, builds the token.Security/Core/Authentication/Provider/Provider.php — the actual rules: timestamp window check, nonce replay check, digest comparison.Username, PasswordDigest, Nonce, Created are held and validated.escape:wsseauthentication:nonces:delete command — this tells you the retention/lifecycle contract.Everything else in the bundle is Symfony wiring you will not reuse.
First use case to target. An inbound partner endpoint: POST /api/partner/orders authenticated only by an X-WSSE header, no session, no CSRF, no cookies. Your first deliverable is a middleware plus a known-good test vector.
// app/Http/Middleware/WsseAuthenticate.php
$header = $request->header('X-WSSE');
if (! $header || ! preg_match(
'/UsernameToken\s+Username="([^"]+)",\s*PasswordDigest="([^"]+)",\s*Nonce="([^"]+)",\s*Created="([^"]+)"/',
$header, $m
)) {
return $this->challenge();
}
[, $username, $digest, $nonceB64, $created] = $m;
$secret = WsseCredential::for($username)?->plain_secret; // note: plaintext, see Gotchas
$expected = base64_encode(sha1(base64_decode($nonceB64) . $created . $secret, true));
if (! hash_equals($expected, $digest)) return $this->challenge();
if (abs(now()->diffInSeconds(Carbon::parse($created))) > config('wsse.lifetime')) return $this->challenge();
if (! Cache::store('redis')->add("wsse:nonce:{$nonceB64}", 1, config('wsse.nonce_ttl'))) return $this->challenge();
auth()->setUser($user); // or a dedicated guard
return $next($request);
Challenge response must be a proper 401 with the WWW-Authenticate header the bundle would emit:
return response()->noContent(401, ['WWW-Authenticate' =>
sprintf('WSSE realm="%s", profile="UsernameToken"', config('wsse.realm'))]);
Confirm the counterparty's spec before writing code: UsernameToken with or without PasswordDigest, digest algorithm (the bundle defaults to base64 SHA-1, 1 iteration — many partners allow nothing else), timestamp skew tolerance, and whether the password is stored plaintext or digested on their side.
1. Route group, not global middleware. WSSE is a partner/compatibility protocol; keep it off your first-party auth surface.
Route::middleware(['wsse'])->prefix('api/partner')->group(base_path('routes/partner.php'));
Strip web (no session, no cookies, no CSRF) — a stateless group. Add throttle:60,1 explicitly; the package gives you zero abuse protection.
2. Build the guard, not just the middleware. Resolve the user in a WsseGuard implementing Illuminate\Contracts\Auth\Guard so auth()->user(), policies, and $request->user() work normally. Keep the protocol in the guard and the credential lookup in a WsseUserProvider. This is the Laravel analogue of the bundle's provider/listener split, and it's what keeps WSSE from leaking into controllers.
3. Nonce replay check = atomic Cache::add. This is the single most important pattern to port correctly. Cache::add() returns false when the key exists — that is your replay rejection, atomic on Redis/Memcached. Never has() then put(); that's a race condition the bundle avoids and hand-rolled code usually gets wrong.
// config/wsse.php
'lifetime' => env('WSSE_LIFETIME', 300), // seconds of allowed clock skew
'nonce_ttl' => env('WSSE_NONCE_TTL', 900), // >= lifetime, preferably 2-3x
'realm' => env('WSSE_REALM', 'Secured with WSSE'),
'profile' => 'UsernameToken',
'store' => env('WSSE_STORE', 'redis'),
4. Outbound: generate the header, don't reverse-engineer it per call site. If you're the client calling a partner, write one WsseHeaderFactory and inject it into your HTTP client middleware (withHeaders). Mirror the bundle's digest exactly and keep a hard-coded test vector.
final class WsseHeaderFactory
{
public function __construct(private string $username, private string $secret) {}
public function header(?string $created = null): string
{
$nonce = random_bytes(16);
$created ??= now()->utc()->format('Y-m-d\TH:i:s\Z');
$digest = base64_encode(sha1($nonce . $created . $this->secret, true));
return sprintf(
'UsernameToken Username="%s", PasswordDigest="%s", Nonce="%s", Created="%s"',
$this->username, $digest, base64_encode($nonce), $created
);
}
}
5. Pruning is a non-issue with a real cache, an explicit job with a DB. The bundle ships escape:wsseauthentication:nonces:delete <firewall> because its default store is PhpFileCache and it grows monotonically. On Redis, TTL handles it and there's nothing to schedule. If compliance requires a DB-backed nonce table, write php artisan wsse:prune and register it: Schedule::command('wsse:prune')->everyFiveMinutes();.
6. Mirror the bundle's config keys so behavior maps 1:1. lifetime, date_format, encoder.algorithm, realm, profile, and per-firewall provider all become keys in config/wsse.php. When a partner says "your timestamp window is wrong," you want to change one env var, not edit middleware.
7. Test the failure modes, not the happy path. A WsseTest feature suite asserting: valid header passes, wrong digest 401s, stale Created 401s, replayed nonce 401s, missing header returns WWW-Authenticate. Use Carbon::setTestNow() for skew, and freeze the nonce to prove replay rejection.
It will not install cleanly into a modern Laravel app. The ^2.2 line targets Symfony 2/3-era dependencies (doctrine/cache v1, old symfony/security). Expect composer conflicts with current Laravel. Even if it installs, it's inert. Vendor or fork the source for reading if you want it pinned; don't add it to composer.json "for reference."
The password problem is the adoption blocker nobody mentions. WSSE PasswordDigest = sha1(nonce . created . plaintextPassword). You need the plaintext secret server-side. If your users table stores Hash::make() (bcrypt/argon), you cannot do WSSE digest at all — there is nothing to recover. You need a separate column (e.g. wsse_secret, encrypted cast, or Hash::encryptString) provisioned out-of-band per partner account. Decide this on day one; it changes your schema. Symmetrically, on the Symfony side the bundle validates via a MessageDigestPasswordEncoder, which means the user's password must be stored in a comparable form.
Double-encoding the nonce is the classic integration bug. The header carries Nonce base64-encoded. The digest is computed over the raw decoded bytes. Some partners (and some hand-rolled implementations) compute over the base64 string instead. Get a known-good sample request from the counterparty and verify against it before building anything else.
Created is ISO 8601 UTC, always. now()->toIso8601String() gives 2024-01-01T00:00:00+00:00, not ...Z, and timezone offsets break naive parsers. Use now()->utc()->format('Y-m-d\TH:i:s\Z'). The bundle exposes date_format (a full ISO 8601 regex by default) precisely because partners disagree here.
Skew window is bidirectional. The bundle's lifetime (default 300s) rejects timestamps too far in either direction. Check abs() or clock drift on a partner box will cause intermittent, hard-to-reproduce 401s. And never set lifetime: -1 (the bundle allows it, the README warns) — that removes replay protection's time bound entirely.
Nonce TTL must exceed the lifetime window. If lifetime is 300s, a 300s nonce TTL leaves a race where the nonce expires right as the timestamp is still valid. Use 2–3x. This is the bug you'll find in production at 3am.
Don't reuse the default PhpFileCache mental model. It's per-host, not coordinated across app servers, and it accumulates files. In Laravel, default to Redis and one shared store across all instances, or replay protection is per-node and effectively broken behind a load balancer.
Compare with hash_equals, never ==. Timing-safe comparison on the digest. Also avoid logging the computed digest or the secret; log a request ID and the username on failure only.
Return 401 with the challenge, not 403 or 500. Clients (and the bundle's entry point) expect WWW-Authenticate: WSSE realm="...", profile="UsernameToken". A 403 makes partner-side debugging a support ticket.
Debugging recipe. Capture the raw X-WSSE, then in php artisan tinker rebuild the digest with the known secret and compare — one line, and it instantly distinguishes "their client is wrong" from "our regex didn't match." Also confirm preg_match actually matched before destructuring; a whitespace/ordering difference in the header silently yields empty values.
If you do fork it or port it, keep the extension points. The bundle's escape_wsse_authentication config (custom provider/listener/entry-point/encoder classes) and nonce_cache_service_id exist so you can swap stores and hashing without touching the firewall. Reproduce them as bound interfaces (WsseNonceStore, WsseAuthenticator) so swapping Redis → DynamoDB, or SHA-1 → a partner-mandated alternative, is a container change.
Set expectations on ownership. 135 stars, 0 dependents, readme-level maturity: you are the maintainer the moment you adopt. Pin the version, wrap it, and if the partner requirement is firm, budget the ~1 day of integration plus the fork readiness — not because the code is hard, but because the digest/nonce/skew details are exactly where subtle auth bugs live.
How can I help you explore Laravel packages today?