duosecurity/duo_universal_php
Official Duo Security PHP SDK for the Universal Prompt. Add two-factor authentication to PHP apps with OAuth 2.0 token exchange, redirect handling, and callback verification against Duo's service.
Architecture fit. duosecurity/duo_universal_php is not a Laravel package — it is a framework-agnostic PHP SDK for Duo's OIDC-based Universal Prompt, and the distinction drives everything below. It exposes a very small surface: construct a client from an options array (client_id, client_secret, api_host, redirect_uri), call healthCheck(), generateState(), createAuthUrl($username, $state), and exchangeAuthorizationCodeFor2FAResult($code, $username). There is no service provider, no publishable config, no facade, no middleware, no guard, and no test double. That is a thin but honest integration point: the flow logic (authorize redirect → callback → code exchange → JWT validation against Duo's keys) is the hard part and the vendor owns it; the Laravel-shaped scaffolding is trivially ours to build. Fit is therefore "good raw material, zero framework affordances."
Critically, the package deliberately does not manage authentication state. It validates a Duo assertion and hands back claims; it does not log anyone in, does not create a session, and does not decide what "2FA passed" means for your app. That decision stays with us, which is both the flexibility and the primary security risk surface.
Integration feasibility. High, and small. Expect ~3–5 engineer-days for a clean integration plus QA, assuming an existing session-based login. Hard dependencies are ext-curl and firebase/php-jwt; we must pin php-jwt to a patched major (^6) rather than inheriting whatever the vendor's composer.json floor allows, because the algorithm-confusion class of JWT bugs is exactly the failure mode that turns a 2FA gate into an auth bypass. We should also confirm the Packagist identity of the package and that it is genuinely published under Duo/Cisco's vendor account before it touches production — "repository: unknown" plus 26 stars is not a supply-chain signal we can accept at face value for an authentication component.
Technical risk.
| Risk | Severity | Note |
|---|---|---|
| No Laravel-native affordances (SP, config, middleware, fake) | Medium | All built by us; also means no community patterns to copy — the low star count is an ecosystem-support signal, not a vendor-quality signal |
| Auth state machine owned by us | High | Pending-2FA vs. authenticated is where session-fixation and bypass bugs live; needs explicit design review |
| Duo availability becomes login availability | High | Mandatory 2FA means a Duo outage is a full login outage unless a break-glass policy exists |
| Clock skew / JWT expiry validation | Medium | NTP drift produces opaque "invalid JWT" failures; frequent, hard-to-diagnose support tickets |
Config drift (redirect_uri exact match, per-tenant api_host) |
Medium | Fails safe but silently; environment parity must be enforced |
| Token/API consumers can't use a redirect flow | Medium | Sanctum/Passport/mobile clients need a separate story, not a retrofit |
Legacy duosecurity/duo_php (Web v4 / iframe) still in the tree |
High | Dual auth paths double the attack surface and the test matrix |
Key questions.
Stack fit. Clean. No conflict with Fortify, Jetstream, Breeze, Sanctum, Socialite, or Laravel's session guards — the SDK touches none of them. It is PHP 8.x-compatible and uses cURL directly, so it will not fight Guzzle version constraints in a typical Laravel dependency graph. The only composer-level diligence is the firebase/php-jwt major and any transitive floors that would force a Laravel downgrade (unlikely, but verify before committing).
Build vs. wrap. Wrap, don't fork, and don't adopt a third-party Laravel wrapper — the surface is too small to justify an unvetted dependency in the auth path. Recommended shape:
config/duo.php reading DUO_CLIENT_ID, DUO_CLIENT_SECRET (via encrypted env / secrets manager, never committed), DUO_API_HOST, DUO_REDIRECT_URI, and an explicit fail_open flag.DuoServiceProvider registering the vendor Client as a singleton, constructed from config.DuoAuthenticator interface with a DuoClientAuthenticator implementation and a FakeDuoAuthenticator for tests — this is the single highest-value decision in the whole integration, because it makes every login test hermetic and removes the need for network egress in CI.RequireDuoVerification middleware that gates all authenticated routes on a duo_verified_at session marker.Migration path (legacy Duo Web SDK → Universal Prompt). Assume parallel-run, not cutover:
duosecurity/duo_php and the iframe views only after the legacy path shows zero traffic for a full business cycle.Compatibility notes. Enforce exact redirect_uri parity across local/staging/prod (Duo matches literally — trailing slashes and scheme differences are a common self-inflicted outage). Local development needs outbound egress to api-*.duosecurity.com; where that's blocked, the fake implementation is the answer, not a bypass flag. Confirm the PHP floor against our oldest supported Laravel version before setting the platform requirement.
Sequencing.
| Phase | Deliverable | Gate |
|---|---|---|
| 1 | Spike: end-to-end auth against a Duo trial tenant | Flow works; latency measured |
| 2 | DuoServiceProvider + config + interface + fake |
Unit tests green with no network |
| 3 | Login pipeline: pending-2FA state, callback, middleware | Security review of state machine signed off |
| 4 | Failure policy + observability + runbook | Outage simulation passes (Duo unreachable, clock skew, bad secret) |
| 5 | Cohort rollout behind flag; enrollment campaign | ≥99% enrolled; error budget clean |
| 6 | Legacy path removal | Zero legacy traffic for one cycle |
Maintenance. Low and bounded — the wrapper is a few hundred lines we own, and the vendor owns the protocol. The real maintenance obligations are external: firebase/php-jwt security patches, Duo's signing-key/certificate rotation (handled by the SDK, but a failed fetch is a login outage), and client_secret rotation in the Duo admin console, which is manual and must be rehearsed rather than discovered. Vendor release cadence appears thin (single recent release date, minimal version history), so we should not assume responsive upstream fixes; budget for the possibility of patching the wrapper ourselves.
Support. Two support surfaces: Duo/Cisco for tenant and configuration issues, and our own first line for everything that manifests as "I can't log in." The SDK throws a typed exception carrying a Duo error code (invalid_client, access_denied, expired_token, etc.) — surfacing that code in logs and in the user-facing message is what converts these tickets from multi-hour investigations into self-service. A written runbook mapping error codes to causes is a phase-4 deliverable, not a nice-to-have.
Scaling. Effectively free. The flow adds one outbound HTTPS round trip on the login path plus a healthCheck() call — realistically 50–200 ms depending on region and whether the health check is per-login or cached. Two things matter: (a) cURL is blocking, so this consumes a PHP-FPM worker for the duration; at high login concurrency this is a real capacity line item, and caching the health check result for a short TTL is the cheap mitigation; (b) the app side remains stateless apart from the session marker, so horizontal scaling is unaffected and there is no shared cache to provision. Do not cache validated assertions — cache only the health check.
Failure modes and policy. The decision that must be made explicitly, in writing, before launch:
fail_closed (correct default for privileged roles), all logins stop; with fail_open (acceptable for low-risk consumer roles only), 2FA silently degrades to single-factor. A per-role policy — fail-closed for admins, fail-open for read-only users — is usually the right compromise, and it must be a config value, not a code path someone "fixes" during an incident.redirect_uri / api_host: fails immediately and loudly in staging if staging actually exercises the real tenant — which it must.state: prevented only if the session marker is single-use and TTL-bounded; this is the one place I'd insist on an explicit test that asserts a second callback with the same state is rejected.Ramp-up. Low for any Laravel developer — the SDK's mental model (OIDC authorization code) is standard and the code is short. The non-obvious knowledge is operational, not technical: error-code triage, the fail-open/closed policy, rotation procedure, and the enrollment-coverage dashboard. Budget half a day of knowledge transfer and keep the runbook next to the on-call rotation. The overall opportunity score (0.43) reads correctly: this is a small, well-scoped, low-differentiation dependency whose value is almost entirely determined by the migration deadline and the availability policy we wrap around it — not by the package itself.
How can I help you explore Laravel packages today?