One-time passwords - authenticator applications (TOTP, RFC 6238) - as a second factor of omnisense/omniauth, under any PHP host, over spomky-labs/otphp: the library scheb/2fa verifies with, on the same parameters, so the secrets an application keeps today go on working and a code scheb/2fa accepts is accepted here - once: the same code is refused the second time. The secrets can be sealed at rest with libsodium (Marco's decision QA5). It calls no service: everything happens between the server and the person's phone.
new TotpMethod($accounts, new SealedTotpSecrets($secrets, SodiumSecretBox::fromBase64($key)), new CacheUsedSteps($pool));
This package was omniauth/totp until 2026-10-23 (renamed: TOTP today, HOTP possible). It
replaces omniauth/totp, and the former namespace Omniauth\Totp\ stays an alias of
Omniauth\Otp\ for one release (Legacy/: each former class name loads the new class, with a
deprecation notice). The factor keeps its name, totp (Factor::Totp).
Installation
composer require omnisense/omniauth omniauth/otp
Requires PHP 8.2 with ext-sodium (bundled with PHP), omnisense/omniauth (1.x),
spomky-labs/otphp (^11.3), paragonie/constant_time_encoding (^2.6 or ^3.0), psr/cache,
psr/simple-cache and psr/clock. No framework. Under omnibridge/symfony and omnibridge/laravel,
the totp key turns it on (Hosts).
Options
new TotpMethod(...):
| Argument | Type | Default | |
|---|---|---|---|
accounts |
Omniauth\Contract\Accounts |
required | the handle (the steps used are per account) and the identifier (the QR address's label) |
secrets |
Omniauth\Contract\TotpSecrets |
required | the base32 secrets (omnibase: user.secret); a SealedTotpSecrets to seal them |
used |
Contract\UsedSteps |
required | the steps already used: CacheUsedSteps on a cache |
leeway |
int, 0 to period - 1 |
0 (TotpMethod::LEEWAY) |
seconds a code is accepted before and after its period (scheb/2fa's leeway) |
period |
int, at least 1 |
30 (PERIOD) |
|
digits |
int, 6 to 10 |
6 (DIGITS) |
|
algorithm |
string |
sha1 (ALGORITHM) |
otphp's digest name |
issuer |
?string |
null |
the QR address's issuer (scheb's issuer) |
server |
?string |
null |
appended to the label after @ (scheb's server_name) |
clock |
?Psr\Clock\ClockInterface |
null: SystemClock |
Values outside those ranges throw an InvalidArgumentException from the constructor.
| Class | Constructor | |
|---|---|---|
CacheUsedSteps |
(CacheItemPoolInterface|CacheInterface $cache, string $prefix = 'omniauth.totp.') |
the last step each account used, in any PSR-6 pool or PSR-16 cache, keyed by the handle's SHA-256 |
SodiumSecretBox |
(string $key): 32 raw bytes; SodiumSecretBox::fromBase64($key): base64, standard or URL-safe, padded or not |
generateKey() draws a key (base64), isSealed($stored), seal(), open(); PREFIX is otp:v1: |
SealedTotpSecrets |
(TotpSecrets $store, Contract\SecretBox $box) |
the store with its secrets sealed: secret() opens, has() does not, enable() seals |
Capabilities
a second factor (Method\SecondFactor): enrolled() |
yes: the account has a secret (SealedTotpSecrets::has() without opening it) |
begin() (Method\Challenging) |
no: the code is on the phone, nothing to send |
set up from the settings (Method\Enrolling) |
yes: a secret and its otpauth:// address, then a first code |
turned off (Method\Revocable) |
yes: TotpSecrets::disable() |
| a code once (RFC 6238 §5.2) | yes, and none older than the last one used |
| scheb/2fa's parameters, secrets and QR address | yes (The same as scheb/2fa) |
| HOTP (counter-based codes) | no |
| secrets sealed at rest | yes, with SealedTotpSecrets and SodiumSecretBox |
TotpMethod also gives uri($account, ?string $secret = null) (the otpauth:// address of a
secret, or of the account's own: the settings' QR code; null when it has none), code($secret, ?int $timestamp = null) (the code of a moment) and TotpMethod::secret() (a new secret: 32 random
bytes, base32, upper case, unpadded - 52 characters, as scheb/2fa draws them).
The same as scheb/2fa
| scheb/2fa | TotpMethod |
|
|---|---|---|
| algorithm, period, digits | omnibase's User::getTotpAuthenticationConfiguration(): SHA1, 30 s, 6 |
ALGORITHM, PERIOD, DIGITS |
| leeway | scheb_two_factor.totp.leeway: 0 |
LEEWAY = 0 (seconds, as scheb's) |
| verification | TOTP::verify($code, null, $leeway), spaces stripped |
the same call |
| secret | TotpAuthenticator::generateSecret(): 32 bytes, base32 upper, unpadded |
TotpMethod::secret() |
| QR code's address | getQRContent(): label the identifier (@ server name when set), issuer when set |
uri(): the same address |
| the same code twice | accepted | refused (bad-code, reason replayed) |
Hosts
omnibridge/symfony (omniauth.yaml) |
omnibridge/laravel (config/omniauth.php) |
|
|---|---|---|
| on | totp: ~ |
'totp' => [...] |
leeway, period, digits, issuer, server_name |
totp.*, the defaults above |
totp.*, the same |
| the steps used | CacheUsedSteps on throttle.pool |
CacheUsedSteps on the throttle's store or pool |
secret_key |
totp.secret_key: a lazy SodiumSecretBox, SealedTotpSecrets decorating the totp_secrets store |
totp.secret_key: the TotpSecrets binding wrapped the same way |
The two hosts of one database must hold the same key (OMNIAUTH_TOTP_KEY on omnibase sites).
The second factor's page names this factor totp, as scheb/2fa did.
Errors
The failures a sign-in or an enrolment answers are listed in The method:
bad-code (reasons replayed, unreadable-secret), not-enrolled, expired. The exceptions:
| Exception | When |
|---|---|
Exception\UnreadableSecret (an OmniauthException, \RuntimeException) |
a sealed secret that does not open: another key, an altered, truncated or non-base64 value. Its message names neither the key nor the secret |
\InvalidArgumentException |
TotpMethod with a period, leeway or digits out of range; a box key that is not 32 bytes or not base64; AuthenticatorApp::scan() of an address that is not TOTP |
Verified, and not
Run in omniauth's harness on 2026-10-25 (PHP 8.4, scheb/2fa 8.6 installed beside): omniauth/otp's
47 tests green. They cover RFC 6238's SHA1 vectors; a password then a code through the conductor; a
code once (the same code in its period refused, the next period's accepted, the same code good for
another account, an earlier code inside the leeway refused after a later one); the leeway in
scheb's seconds; five wrong codes and the account throttled whatever the address; the enrolment and
the revocation; the address with a server name and an issuer; against scheb/2fa itself, second
by second across two period boundaries for leeways 0, 5 and 29, scheb's checkCode() and a first
use here answer the same, and the QR address equals getQRContent(); the sealed secrets (format,
nonce, another key, altered and truncated values refused with UnreadableSecret, a sign-in and an
enrolment on a sealed store); docs/installation.md run as written; in plain PHP (BareTest),
nothing loaded but omniauth, otphp, paragonie's encoding, the PSR interfaces and
symfony/password-hasher; the former Omniauth\Totp\ names.
Not verified: a real phone's application against a site; concurrent tries of the same code against
CacheUsedSteps (read then written, not atomic: two requests with the same code at the same
instant may both pass; the throttle, five codes per 15 minutes per account, bounds the rest).
Propose a change docs/index.md
Installation
composer require omnisense/omniauth omniauth/otp
It requires omnisense/omniauth, spomky-labs/otphp (the library scheb/2fa verifies with) and the
PSR cache and clock interfaces. No framework: the secrets stay in your table, behind omniauth's
Contract\TotpSecrets; the steps already used, in any PSR-6 pool or PSR-16 cache.
The whole thing by hand - the in-memory stores of Omniauth\Testing stand in for yours, and
Omniauth\Otp\Testing\AuthenticatorApp for the person's phone:
<?php
require 'vendor/autoload.php';
use Omniauth\ArrayState;
use Omniauth\Context;
use Omniauth\Factor;
use Omniauth\Method\Methods;
use Omniauth\Omniauth;
use Omniauth\Password\PasswordMethod;
use Omniauth\Testing\ArrayCache;
use Omniauth\Testing\FixedClock;
use Omniauth\Testing\InMemoryStore;
use Omniauth\Testing\TestAccount;
use Omniauth\Otp\CacheUsedSteps;
use Omniauth\Otp\Testing\AuthenticatorApp;
use Omniauth\Otp\TotpMethod;
use Symfony\Component\PasswordHasher\Hasher\NativePasswordHasher;
$clock = new FixedClock(); // yours: none (the system's); fixed here, the block runs in one go
$hasher = new NativePasswordHasher();
$store = new InMemoryStore($ringo = new TestAccount('42', 'ringo', 'ringo@example.org', $hasher->hash('yellow-submarine')));
$session = []; // $_SESSION, in a real page
$omniauth = new Omniauth(
accounts: $store,
methods: new Methods([
new PasswordMethod($store, $store, $hasher),
new TotpMethod($store, $store->totpSecrets(), new CacheUsedSteps(new ArrayCache($clock)), clock: $clock), // your cache pool
]),
state: new ArrayState($session),
clock: $clock,
);
$context = new Context(origin: 'https://example.org', host: 'example.org', ip: '203.0.113.7');
// From the account's settings: a secret and its address, drawn as a QR code by your page.
$enrolment = $omniauth->enrol(Factor::Totp, $ringo);
$challenge = $enrolment->begin($context);
echo $challenge->data['uri'] === 'otpauth://totp/ringo?secret='.$challenge->data['secret'] ? "otpauth://totp/ringo?secret=...\n" : '';
// otpauth://totp/ringo?secret=...
$phone = AuthenticatorApp::scan($challenge->data['uri'], $clock);
echo $enrolment->confirm($context, ['code' => $phone->code()])->isDone() ? "set up\n" : "not set up\n";
// set up
// Half a minute later: a password, then the code of the moment (the enrolment's is used up).
$clock->advance(30);
$result = $omniauth->verify('password', $context, ['identifier' => 'ringo', 'password' => 'yellow-submarine']);
echo $result->status->value, ': ', $result->offered[0]->value, "\n";
// second-factor-required: totp
$result = $omniauth->verify('totp', $context, ['code' => $phone->code()]);
echo $result->status->value, ' by ', implode(' + ', array_map(fn ($f) => $f->value, $result->passed)), "\n";
// authenticated by password + totp
A code is good once: the same code again, even within its 30 seconds, is refused (bad-code, reason
replayed) - scheb/2fa accepted it again.
The parameters are scheb/2fa's, as every site sets them today: SHA1, 30 seconds, 6 digits,
leeway: 0 (TotpMethod::ALGORITHM, PERIOD, DIGITS, LEEWAY), no server name, no issuer. The
secrets those sites keep (user.secret, base32) are read as they are.
Run in omniauth's harness on 2026-10-25 (the block as it is, Tests/DocsTest.php), it prints:
otpauth://totp/ringo?secret=...
set up
second-factor-required: totp
authenticated by password + totp
Next: the method.
Propose a change docs/installation.md
The method
new TotpMethod(
accounts: $accounts, // Omniauth\Contract\Accounts: the handle, the identifier (the QR code's label)
secrets: $secrets, // Omniauth\Contract\TotpSecrets (omnibase: user.secret)
used: new CacheUsedSteps($pool), // Contract\UsedSteps: a code once
leeway: 0, // seconds before and after a period, as scheb's `leeway`
period: 30, digits: 6, algorithm: 'sha1', // scheb/2fa's, as omnibase's User gives them
issuer: null, server: null, // scheb's `issuer` and `server_name`: the QR code's label
clock: null, // PSR-20; the system's by default
);
A second factor: EnrolledSecondFactors asks it after a password, a link or an identity provider
of an account that has a secret (enrolled()), and offers the backup codes beside it.
Signing in
$omniauth->verify('totp', $context, ['code' => '123 456']), once a first factor passed. Spaces
are ignored. otphp verifies the code at this moment within the leeway, exactly as scheb/2fa calls
it; then the code's time step is claimed for the account (UsedSteps::claim()): a step already
claimed, or an earlier one, is refused.
| Failure | Reason | When |
|---|---|---|
bad-code |
- | not the code of the moment (or empty) |
replayed |
the right code, already used - or older than the last one used | |
unreadable-secret |
the stored secret is not base32 | |
not-enrolled |
- | the account has no secret |
A sealed store whose value does not open under the key (another key, an altered row) throws
Exception\UnreadableSecret out of verify(): the sign-in fails loudly, never as "no secret".
The code's step is claimed only once otphp has verified it; a code that matches at the leeway's
edge claims that step (intdiv($at, $period)), and the claim is remembered until the code could
no longer verify ((step + 1) * period + leeway - now + 1 seconds).
Setting it up, from the settings
| Step | Call | Gives |
|---|---|---|
| begin | $omniauth->enrol(Factor::Totp, $account)->begin($context) |
Challenge::none(['secret' => ..., 'uri' => 'otpauth://totp/ringo?secret=...']); the secret waits in the enrolment (the session), not in the store |
| confirm | ->confirm($context, ['code' => ...]) |
Enrolled::done() and TotpSecrets::enable(); a wrong code keeps the secret waiting |
A confirmation with no secret waiting (no begin(), or the enrolment's ten minutes gone) is
Enrolled::failed(expired); a first code is claimed like any other, so the same code does not
sign in again right after.
The page draws the QR code of uri (omnibase keeps endroid/qr-code for that) and may show
secret for typing in. $method->uri($account) gives the address of the account's own secret
(today's qr_code_totp route).
Turning it off
$omniauth->revoke(Factor::Totp, $account): TotpSecrets::disable(), and the conductor makes the
trusted devices fall back.
Sealed secrets (QA5)
$secrets = new SealedTotpSecrets($store, SodiumSecretBox::fromBase64($key)); // $key: base64 of 32 bytes, from the vault
new TotpMethod($accounts, $secrets, new CacheUsedSteps($pool));
What the store keeps: otp:v1: then the base64url (unpadded) of the 24-byte nonce followed by
sodium_crypto_secretbox($secret, $nonce, $key) - 130 characters for a 52-character secret. A
value without the prefix is a secret kept in clear before the box: open() returns it as it is, so
a site switches on the box first and rewrites its rows after; seal() leaves a sealed value as it
is, so the rewrite can run twice. SealedTotpSecrets::has() answers whether an account has a secret
without opening it (TotpMethod::enrolled() asks it at every sign-in); secret() opens it, for
verify(), enrolConfirm()'s store and uri() (the settings' QR code). A value that does not open
throws Exception\UnreadableSecret, whose message names neither the key nor the secret.
The key. SodiumSecretBox::generateKey() draws one (base64). It lives in the host's secrets
vault or environment - for omnibase sites OMNIAUTH_TOTP_KEY, read by omnibridge/symfony from
omniauth.yaml's totp.secret_key and by omnibridge/laravel from config/omniauth.php's
totp.secret_key -, never in a file of the repository: development keeps its own in .env.local
(Symfony) or the twin's .env (both untracked), the test environment a fixed test key of its own.
Both hosts of one database must hold the same key.
Rewriting the rows. Once the box is on, the secrets kept in clear are rewritten by the
application (omnibase sites: a Doctrine migration, up seals each user.secret that lacks the prefix,
down opens each that has it), then SELECT COUNT(*) FROM user WHERE secret IS NOT NULL AND secret <> '' AND secret NOT LIKE 'otp:v1:%' is 0.
Rotating the key. No code is needed for a rotation, only an order: (1) draw a new key; (2) with
the old key and the new one at hand, rewrite every row - open each sealed value with a box on the old
key, seal it with a box on the new key (a value that already opens with the new key is left; a
secretbox under the wrong key fails to open, it never yields a wrong secret, so the two are told
apart safely); (3) put the new key in the vault of every host of that database at once, and drop the
old one. Between (2) and (3) the hosts must not write new secrets (enrolments) - a short maintenance
window, or a box that tries the new key then the old one, which SodiumSecretBox could gain the
day a rotation needs it without a window. A compromised key means rewriting with a new key and,
since a secret read under the old key is known, asking the accounts concerned to enrol again. The
prefix's version (v1) names the format, not the key: a new format would be v2, read beside v1.