TOTP & HOTP
Secrets are the base32 text an authenticator app displays. Enrol a user with a fresh secret and a provisioning URI (usually rendered as a QR code), then verify their codes:
use RoundlyConsulting\Crypto\Facades\Crypto;
$secret = Crypto::random()->secret(32); // enrol
$uri = Crypto::provisioningUri($secret, '[email protected]', 'Acme Inc');
$code = Crypto::totp()->codeAt($secret); // SHA1, 6 digits, 30s (default profile)
$matchedStep = Crypto::totp()->verify($secret, $userCode); // int|false, timing-flat, drift window
Crypto::totp(digits: 8)->verify($secret, $userCode); // a custom profile
Crypto::hotp()->at($secret, $counter); // RFC 4226, counter-basedOr call the classes the facade fronts directly:
use RoundlyConsulting\Crypto\Otp\Totp;
use RoundlyConsulting\Crypto\Otp\ProvisioningUri;
use RoundlyConsulting\Crypto\Random\Secret;
$secret = Secret::base32(32); // enrol
$uri = ProvisioningUri::totp($secret, '[email protected]', 'Acme Inc');
$totp = new Totp; // SHA1, 6 digits, 30s (default profile)
$code = $totp->codeAt($secret);
$matchedStep = $totp->verify($secret, $userCode); // int|false, timing-flat, drift window
if ($matchedStep === false) {
// reject
}verify() returns the matched timestep index or false. A match at timestep 0 returns the int 0, so always compare with === false.
Profiles and the drift window
The default profile — SHA-1, 6 digits, 30 seconds — matches every mainstream authenticator app. Custom profiles use named arguments; digits must be 6–10 and the period at least 1, otherwise InvalidOtpParameterException is thrown:
use RoundlyConsulting\Crypto\Otp\OtpAlgorithm;
use RoundlyConsulting\Crypto\Otp\Totp;
$totp8 = new Totp(OtpAlgorithm::Sha256, digits: 8, period: 60);
$totp->verify($secret, $userCode, window: 2); // ±2 steps (max Totp::MAX_WINDOW = 10)
$totp->verify($secret, $userCode, timestamp: $submittedAt); // evaluate at a specific instantverify() checks every step in the ± window without an early return, so a late match costs the same as an early one, and every comparison is constant-time. Malformed codes are rejected before any HMAC work. The window must be 0–10 (Totp::MAX_WINDOW); anything else throws. The current time is read via CarbonImmutable::now(), so tests can pin it.
Totp methods
| Method | Returns | Notes |
|---|---|---|
at($secret, $timestep) | string | The code at an explicit timestep index. |
codeAt($secret, $timestamp = null) | string | The code at a unix timestamp (default now). |
timestepAt($timestamp) | int | The timestep index for a timestamp. |
verify($secret, $code, $window = 1, $timestamp = null) | int|false | The matched timestep, or false. |
HOTP and provisioning URIs
Hotp computes the counter-based RFC 4226 value; ProvisioningUri builds the otpauth:// link an authenticator app imports. The issuer is always an explicit argument:
use RoundlyConsulting\Crypto\Otp\Hotp;
use RoundlyConsulting\Crypto\Otp\ProvisioningUri;
$hotp = new Hotp; // SHA-1, 6 digits
$code = $hotp->at($secret, $counter); // RFC 4226 value for a base32 secret + counter
$uri = ProvisioningUri::totp(
secret: $secret,
label: '[email protected]',
issuer: 'Acme Inc',
// algorithm: OtpAlgorithm::Sha1, digits: 6, period: 30 (defaults)
);
// otpauth://totp/Acme%20Inc:alice%40example.com?secret=…&issuer=Acme%20Inc&algorithm=SHA1&digits=6&period=30OTP algorithms come from Otp\OtpAlgorithm (Sha1, Sha256, Sha512). HOTP truncation is computed without relying on 64-bit integers, so codes are correct on 32-bit PHP too.
Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.