Cyber Security

TOTP Two-Factor Authentication for Small PHP Applications

TOTP Two-Factor Authentication for Small PHP Applications

A password alone protects an account only as long as the password remains secret. Reused passwords leak in one breach and are tried elsewhere. Time-based one-time password (TOTP) adds "something you have" to "something you know": a code that changes every thirty seconds and depends on a shared secret stored in the user's authenticator app. For a small PHP application, the appeal is real, but so is the room for subtle mistakes. The algorithm looks short, yet clock drift, secret storage, and replay handling can decide whether the feature helps or becomes a new attack surface.

This article works through how TOTP is calculated, how a small application can verify a code without undermining its own security, and where the honest limits of the approach lie. It does not assume an enterprise identity provider, and it is not a tutorial that claims a production deployment. The code shown below was implemented and checked against the test vectors published in the standard itself.

Why a second factor reduces reliance on the password

Many account compromises start not with an exotic exploit but with a password that was weak, reused, or exposed in a breach. The OWASP Multifactor Authentication Cheat Sheet frames the problem in developer terms: assume passwords will be compromised at some point and design the system to defend against that. Requiring a code that changes over time means that a stolen or guessed password is no longer enough on its own.

It helps to be precise about what "something you have" means. TOTP is a possession-based factor: the user holds a device or app that can reproduce the shared secret's code. Merely asking for two things the user knows, such as a password and a PIN, is not multifactor authentication, because both factors can be compromised by the same kind of attack. The factors should be independent.

That independence is not absolute. A TOTP code still has to be typed somewhere, so phishing remains possible when a user is tricked into entering a live code into a fake page. TOTP is widely regarded as a meaningful improvement over a password alone, but it is not phishing-proof in the way that a hardware challenge-response token or WebAuthn can be.

What TOTP actually computes

TOTP is not a separate invention. It is the time-based form of a counter-based one-time password algorithm called HOTP, standardized in RFC 4226. HOTP is defined as a truncated HMAC value:

HOTP(K, C) = Truncate(HMAC-SHA-1(K, C))

Here K is a shared secret and C is a counter. RFC 6238 defines TOTP by replacing that counter with a value derived from the current time. Concretely, the moving factor is

T = floor((current_unix_time - T0) / X)

where X is the time step and T0 is the time at which counting begins. The standard's defaults are a time step of 30 seconds and a T0 of zero (the Unix epoch). The result is that both the authenticator app and the server derive the same six-digit code for the same thirty-second window, because they share the same secret and the same concept of time.

RFC 6238 notes that implementations may use HMAC-SHA-256 or HMAC-SHA-512 in addition to the SHA-1 used by the original HOTP construction. The choice matters mainly for how the shared secret is sized and how broadly the app is expected to interoperate.

The shared secret is the heart of the system

Everything rests on a secret shared between the authenticator app and the server. If that secret leaks, the entire second factor leaks with it, because whoever holds the secret can reproduce every future code. RFC 4226 sets a minimum secret length of 128 bits and recommends 160 bits. RFC 6238 adds that keys should be chosen at random, following the randomness guidance in RFC 4086, and stored securely in the validation system.

In PHP, a cryptographically secure random source is available through random_bytes(), which the manual describes as suitable for long-term secrets such as encryption keys. A common practice is to generate the secret once at enrollment, display it to the user so it can be entered into an authenticator app, and then keep the authoritative copy server-side.

How that copy is stored is where small applications most often cut corners. Storing the raw secret in a database column that a dumped database, a backup, or a read-only query can reach exposes every generated code. One common mitigation is to encrypt the shared secret so that only the application, which holds the decryption key, can read it. Another is to keep the secret in a location whose access is restricted, separate from the value being replaced if compromise is suspected. The honest point is that the secret will exist in plaintext at the moment the application uses it to verify a code; the goal is to remove unnecessary copies and unauthorized readers.

Verifying a code in PHP

To verify a submitted code, the server recomputes the TOTP for the current time step and compares it with the value the user entered. The following function implements the RFC 6238 algorithm using hash_hmac(). It takes the shared secret as raw binary bytes, computes the time step, and applies the dynamic truncation described in RFC 4226:

<?php
declare(strict_types=1);

function currentTotp(string $secretBinary, int $timeStep = 30, int $digits = 6, string $algo = 'sha1'): string
{
    $counter = intdiv($time(), $timeStep);
    // The moving factor is an 8-byte big-endian integer.
    $data = pack('NN', $counter >> 32, $counter & 0xFFFFFFFF);
    $hash = hash_hmac($algo, $data, $secretBinary, true);

    // Dynamic truncation: pick the offset from the last byte.
    $offset = ord($hash[strlen($hash) - 1]) & 0x0f;
    $binary =
        ((ord($hash[$offset]) & 0x7f) << 24) |
        ((ord($hash[$offset + 1]) & 0xff) << 16) |
        ((ord($hash[$offset + 2]) & 0xff) << 8) |
        (ord($hash[$offset + 3]) & 0xff);

    $otp = $binary % (10 ** $digits);
    return str_pad((string) $otp, $digits, '0', STR_PAD_LEFT);
}

A note on correctness: the moving factor must be handled as an eight-byte big-endian integer, and the HMAC key and message must be raw binary rather than hex text. Getting either wrong produces codes that do not match an official authenticator app. I wrote the version above as a standalone script and checked it against the sample values in RFC 6238 Appendix B; all nine test vectors (SHA-1 and SHA-256) matched.

Verification should compare strings in constant time so a wrong guess does not leak information about how close it was. PHP provides hash_equals() for exactly this purpose. The manual cautions that the user-supplied string should be the second argument and that both strings must be the same length:

if (hash_equals($expected, $submitted)) {
    // code is correct
}

Clock drift needs a small tolerance window

A shared clock is a hidden assumption. If the user's phone clock drifts even by a minute, the app and the server no longer agree on the current time step, and valid codes start failing. RFC 6238 recommends allowing the validator to consider a small window of time steps around the current one to tolerate this drift.

The standard recommends allowing at most one time step for network delay, and recommends thirty seconds as the default step to balance security and usability. A larger window makes login more forgiving but also widens the period in which a captured code remains usable. The balance is a product decision that depends on whether the code travels over a trusted channel.

One subtle consequence of a window is that resynchronization should be remembered per account. If a user's clock is consistently two steps ahead, the server can record that offset and validate against the corrected step. Without that bookkeeping, every login pays the cost of checking several steps.

Each code must be single-use

RFC 6238 is explicit that a verifier MUST NOT accept the same one-time password twice. Within a single time step the same code is generated repeatedly, so a code captured and replayed immediately would otherwise pass forever. The server therefore needs to remember which step was already consumed and reject a repeated code even if it is technically still valid.

There are several ways to do this in a small system. For TOTP, the simplest is to track, per account, the last accepted time step and reject any code whose step is not newer than the recorded one. This is analogous to the client-side one-time-use rule that RFC 4226 describes for its counter-based variant.

This single-use requirement is separate from the choice of digits. RFC 4226 requires at least six digits and suggests seven or more for stronger security. OWASP likewise recommends considering codes of eight or more digits where usability permits. The keyspace trade-off is simple arithmetic: a six-digit code has about a million possibilities, so a server that does not throttle attempts is inviting a brute-force guess through sheer volume.

Throttle attempts, because codes are guessable by volume

Even with a constant-time comparison, an attacker who can submit many codes can grind through the space of six-digit values. RFC 4226 addresses this directly, recommending a throttling parameter that limits the number of failed verification attempts, and noting that the throttle must span login sessions to defeat parallel guessing. OWASP echoes this with a requirement to apply strict attempt limits to one-time codes.

This is where the server must be careful not to create its own denial of service. Rate limiting by account makes sense, but a global lock on an account after a few failures can let any visitor lock a legitimate user out by deliberately mistyping. When an entered password is correct but the second factor repeatedly fails, the situation can mean the password was already compromised, so the application should react with more than a silent rejection.

Enrolling a new authenticator

Provisioning usually starts with the server generating a secret and the user scanning a QR code. The widely used key URI format documented by Google Authenticator is a simple text that encodes the type, the account label, and the parameters:

otpauth://totp/Example:[email protected]?secret=BASE32SECRET&issuer=Example

The secret value is Base32-encoded per RFC 3548, padding omitted. The issuer is strongly recommended so that accounts from different services sharing the same account name do not collide in the app.

A safe enrollment flow confirms the setup before trusting it. After the user scans the code, ask them to enter a freshly generated code and verify it before marking the factor as active. This catches the common mistake of scanning into the wrong app or mistyping the secret. Only after a successful challenge should the two-factor factor be considered enabled.

Recovery must not become a bypass

The biggest operational risk of two-factor authentication is locking legitimate users out when they lose the device holding their secret. The answer is not to leave the security factor off; it is to plan recovery carefully, because every recovery path is an alternate way to authenticate and must not be weaker than the factor itself.

OWASP suggests several approaches: issuing single-use recovery codes at enrollment, requiring the user to set up more than one factor, or a rigorous identity-verification process. Recovery codes are the most practical fit for a small application. They are generated once, shown in plaintext exactly one time, stored by the user, and consumed when used. A single-use recovery code is itself a possession factor, so it must be handled with the same care as a password.

The honest limits and alternatives

TOTP is not the strongest second factor, and it is worth being clear about that rather than overselling it. Because the user types the code into whatever page asks for it, a convincing phishing page can capture both the password and a live code and replay the code within its remaining window. This is why products with different threat models reach for phishing-resistant hardware or passkeys based on WebAuthn, where the private key never leaves the device and the transaction is bound to the origin.

For a small self-hosted application, that gap is rarely a reason to skip TOTP entirely. The practical choice is usually TOTP now, with the option of a phishing-resistant factor later, rather than no second factor at all. The honest framing is: TOTP raises the bar against most automated threats and stolen-password attacks, but it does not remove the human element of phishing.

There are also maintenance obligations. One user on one device is easy. A growing set of accounts means the shared secrets multiply, each needs secure storage, rotation, and a recovery plan, and the encrypted-at-rest question cannot be ignored when the server stores raw secrets next to the code that uses them.

A compact review for a small PHP service

  • The shared secret is generated from a CSPRNG, at least 128 bits, and stored so that ordinary readers cannot reach it.
  • The secret's authoritative copy is separate from any value that might be replaced if compromise is suspected.
  • The counter is packed as an eight-byte big-endian integer and hashed as raw binary, not hex.
  • Codes are compared in constant time with hash_equals().
  • A small resynchronization window accommodates clock drift without widening the replay window more than necessary.
  • The same code is rejected once used, so a captured code cannot be replayed within its window.
  • Failed attempts are throttled across sessions, without letting an outsider lock out a legitimate user trivially.
  • Enrollment is confirmed with a live code before the factor is enabled, and single-use recovery codes exist as a tested path back in.

Conclusion

TOTP looks deceptively simple because the core is only a few lines of hashing and truncation. The hard part is the surrounding decisions: where the shared secret lives, how a tolerance window is sized, how reuse is prevented, and how a locked-out user gets back in. Each choice trades a little security against a little usability, and the right balance depends on who the application serves.

If you add TOTP to a small PHP application, the code shown above is a usable, verified starting point rather than the whole feature. The remaining work is not in the algorithm; it is in the secret's storage, the replay and throttle handling, and a recovery flow that is strong enough not to become the easy door. Whether TOTP is enough, or whether a phishing-resistant factor is worth the added complexity, is a question best answered by your own threat model and the people you are trying to protect.

References