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.
composer require duosecurity/duo_universal_php
Before writing code, create a Web SDK application in the Duo Admin Panel. That gives you three values you cannot derive from anywhere else: the Client ID, the Client Secret, and the API hostname (api-xxxxxxxx.duosecurity.com). Register your callback URL there too — it must match byte-for-byte what you pass to the SDK.
// config/services.php
'duo' => [
'client_id' => env('DUO_CLIENT_ID'),
'client_secret' => env('DUO_CLIENT_SECRET'),
'api_host' => env('DUO_API_HOST'), // hostname only, no https://
'redirect_uri' => env('DUO_REDIRECT_URI'), // full absolute URL
],
use Duo\DuoUniversal\Client;
$duo = new Client(
config('services.duo.client_id'),
config('services.duo.client_secret'),
config('services.duo.api_host'),
config('services.duo.redirect_uri'),
);
$duo->healthCheck(); // throws DuoException on bad creds/host/network
Where to look first: src/Client.php in the package (it is small — read it end to end) and the examples/ directory in the repo. The class exposes essentially four things you care about: healthCheck(), generateState(), createAuthUrl($username, $state), and exchangeAuthorizationCodeFor2FAResult($code, $state). Everything else in the SDK is JWT verification and cURL plumbing you don't touch.
First use case: gate a single high-value route (admin panel, billing settings) behind Duo rather than retrofitting the whole login flow. That gets you a working state → prompt → callback → session loop in an afternoon with a small blast radius.
Two-phase controller flow. The prompt and the callback are separate requests. Keep state in the session between them.
Route::get('/duo/prompt', [DuoController::class, 'prompt'])->name('duo.prompt');
Route::get('/duo/callback', [DuoController::class, 'callback'])->name('duo.callback');
public function prompt(Request $request)
{
$state = $this->duo->generateState();
$request->session()->put('duo.state', $state);
$request->session()->put('duo.intended', url()->previous());
$request->session()->put('duo.started_at', now());
return redirect(
$this->duo->createAuthUrl($request->user()->email, $state)
);
}
public function callback(Request $request)
{
$expected = $request->session()->pull('duo.state');
if (! $expected || ! hash_equals($expected, (string) $request->query('state'))) {
abort(400, 'Invalid 2FA state');
}
try {
$result = $this->duo->exchangeAuthorizationCodeFor2FAResult(
$request->query('code'),
$expected,
);
} catch (DuoException $e) {
report($e);
return redirect()->route('login')->withErrors('Two-factor check failed.');
}
$request->session()->put('duo.verified_user', $result->getUsername());
$request->session()->put('duo.verified_at', now());
return redirect($request->session()->pull('duo.intended', '/'));
}
Wrap the SDK behind your own interface. The Client is a concrete class that talks over cURL, so you cannot inject a mocked HTTP handler in tests. Bind your own TwoFactorProvider contract in a service provider, implement it with Duo\DuoUniversal\Client, and swap in a fake for feature tests and local development. This is the single highest-leverage thing you can do on day one.
Treat the result as an assertion, not an identity source. getUsername() tells you who Duo authenticated; it should match the user you already have in session. Compare them and reject on mismatch — that closes the "user A starts the prompt, user B finishes it" window.
Add a freshness window. Store duo.verified_at and re-prompt after N hours (or on privilege escalation). Middleware is the natural place:
if (! $request->session()->has('duo.verified_at')
|| $request->session()->get('duo.verified_at')->lt(now()->subHours(8))) {
return redirect()->route('duo.prompt');
}
Health check on a schedule, not per request. healthCheck() is a live HTTP round trip. Put it in an Artisan command run by the scheduler, or expose it through a /health endpoint your uptime monitor hits. Doing it in a middleware adds latency to every request and hammers Duo's API.
Fail closed by default, with a documented break-glass. If healthCheck() fails or the callback throws, deny access and alert. If you need a fail-open path for staff during a Duo outage, make it an explicit, logged, role-gated escape hatch — never a silent catch that lets the request through.
api_host is a hostname, not a URL. https://api-xxxx.duosecurity.com silently produces a confusing cURL/connection error. Strip the scheme.redirect_uri mismatches surface late. A trailing slash or http vs https difference usually doesn't break createAuthUrl() — it breaks the token exchange on the callback, so you get an error after the user has already completed the Duo prompt. Verify it in a smoke test against your real environment.pull() it, don't get() it. If a user opens two tabs you'll clobber a single duo.state key; namespace the key by attempt ID in the URL if that matters for your flow.hash_equals when comparing state, and compare it yourself even though the SDK cross-checks it. Defense in depth costs one line.createAuthUrl() must resolve to a Duo user — email vs. username depends on your Duo directory configuration. Aliases configured in the Duo Admin Panel are your fix when it's wrong; there's no SDK-side mapping.iat/exp. Containers with drifting clocks will fail a small percentage of logins. Keep NTP healthy and don't write off sporadic DuoExceptions as noise.DuoException specifically, not \Exception. The SDK throws it for config errors, network failures, and invalid tokens alike — log the message, since it's usually descriptive enough to diagnose without a debugger.Client instance; Laravel's report() on a DuoException won't leak it, but a careless dd($client) or a config dump in an error page will.composer.json, and check the PHP version constraint against your runtime rather than assuming ^1.0 covers your PHP 8.x build.How can I help you explore Laravel packages today?