svc/totp-bundle
A PHP/Symfony bundle for TOTP-based two-factor authentication, generating and validating time-based one-time passwords with seamless framework integration and straightforward configuration for securing user logins.
This is a Symfony bundle, not a Laravel package — despite how it's sometimes tagged. If you're on Laravel, stop here and look at laravel/fortify (which ships TOTP + recovery codes) or pragmarx/google2fa directly. The rest of this assumes you're on Symfony with scheb/2fa-bundle already in play.
The mental model: svc/totp-bundle is a thin UI layer (forms, controllers, Twig templates) over scheb/2fa-bundle + scheb/2fa-totp. It does not implement TOTP, it does not touch secrets directly, and it does not replace Scheb — the security engine is Scheb. This package is a "front door" that gives you enrollment screens and QR rendering out of the box.
Minimal install path:
composer require scheb/2fa-bundle scheb/2fa-totp svc/totp-bundle
Before committing, run composer show svc/totp-bundle and confirm the resolved version is aligned with your Symfony + Scheb major versions. With 3 stars and 0 dependents, the constraint metadata is the only signal you'll get — verify it manually against the composer.json of the package's default branch.
Enable bundles in config/bundles.php (Flex does not auto-register this one):
Scheb\TwoFactorBundle\SchebTwoFactorBundle::class => ['all' => true],
Svc\TotpBundle\SvcTotpBundle::class => ['all' => true],
Find the routes. Look in the repo's src/Resources/config/ (or config/) for the routing file, then import it:
# config/routes.yaml
svc_totp:
resource: '@SvcTotpBundle/Resources/config/routes.yaml'
Verify with php bin/console debug:router | grep -i totp — the route names matter because you'll often need to override them or redirect to them explicitly after login.
Configure Scheb and point its template at this package. Under scheb_two_factor.totp, set the template key to the Twig file shipped by this bundle. Locate it first:
find vendor/svc/totp-bundle -name '*.twig'
First use case to validate end-to-end: an authenticated user lands on the enrollment controller, sees a QR code rendered from an otpauth:// provisioning URI, scans it in Google Authenticator/Authy, enters a 6-digit code, and the flag flips on their User row. If that flow works, the package is doing its job. Everything else — backup codes, re-enrollment, admin reset — is your code.
Where to look first if you get stuck: read Scheb's own docs before this package's. The enrollment flow, the TwoFactorInterface contract, and the firewall config all live in Scheb. This bundle only wraps the presentation.
Make your User entity a TOTP subject. This is the contract that drives everything:
use Scheb\TwoFactorBundle\Model\Totp\TwoFactorInterface;
use Scheb\TwoFactorBundle\Model\Totp\TotpConfiguration;
use Scheb\TwoFactorBundle\Model\Totp\TotpConfigurationInterface;
class User implements TwoFactorInterface
{
private ?string $totpSecret = null;
private bool $totpEnabled = false;
public function isTotpAuthenticationEnabled(): bool
{
return $this->totpEnabled && $this->totpSecret !== null;
}
public function getTotpAuthenticationUsername(): string
{
return $this->getUserIdentifier();
}
public function getTotpAuthenticationConfiguration(): ?TotpConfigurationInterface
{
if ($this->totpSecret === null) {
return null;
}
return new TotpConfiguration($this->totpSecret, TotpConfiguration::ALGORITHM_SHA1, 30, 6);
}
}
Persist totpSecret as a string column with a nullable default null, and gate on totpEnabled separately so enrollment has a two-step lifecycle (generate secret → confirm code → enable).
Treat the secret as unconfirmed until the first code verifies. The package's enrollment flow will populate a secret; your job is to only flip totpEnabled = true after Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Totp\TotpAuthenticatorInterface::checkCode() returns true on a code the user entered. Storing a secret without confirmation means a wrong QR scan can lock the user out permanently.
Wire the firewall gate. In config/packages/security.yaml:
security:
firewalls:
main:
two_factor:
auth_form_path: 2fa_login
check_path: 2fa_login_check
providers: [totp]
Route names here are Scheb's, not this bundle's — check debug:router to confirm what's actually registered and avoid overlapping prefixes with the UI routes.
Sequence your rollout in a feature flag. Typical pattern for an existing app:
totpSecret/totpEnabled columns and ship the entity contract with isTotpAuthenticationEnabled() returning false.Recovery flows are yours. Scheb supports backup codes (scheb/2fa-backup-code), but this UI doesn't render them. Add the recovery-codes bundle and a "show me my codes once" screen as a separate step after successful enrollment, or you will lock users out. At minimum, build an admin "reset 2FA" action — the UI package doesn't provide one and support tickets will arrive.
Rate-limit the verification endpoint. This is a common miss: the enrollment UI renders a form and the check happens on a Scheb route. Put a limiter on 2fa_login_check (Symfony's login_throttling covers it if you route through the login mechanism, or drop in symfony/rate-limiter on the route). The UI package won't do it and Scheb's default is permissive.
Override templates via standard Symfony lookup, not forking. If you need to restyle the QR screen, override the Twig path (templates/bundles/SvcTotpBundle/...) rather than editing the vendor file. When the package inevitably lags a Symfony major, you want your overrides diffable in one place.
For a Laravel team forced to port the UX: don't. Read the templates as design reference (what fields, what messages, what success state), then build the equivalent against Fortify's TwoFactorAuthenticationProvider or pragmarx/google2fa. Two Blade views + a controller is a day's work and you own the patch surface.
The framework mismatch is the #1 trap. The package description is generic "PHP"; the artifact is a Symfony bundle with a Kernel class registration, Twig templates, and service container wiring. Dropping it into a Laravel app "just to try" will not work without a Symfony kernel bridge. If you see a blog post or search result implying this is a Laravel package, that's an indexing artifact.
Bundles must be registered in order and both halves together. If you enable SvcTotpBundle without SchebTwoFactorBundle, you get confusing container errors. If you enable Scheb without configuring providers: [totp] on the firewall, enrollment screens render but the gate never fires — the user logs in with just their password and 2FA is silently skipped. This is a security-control failure, not an availability one, and it's easy to miss on staging because everything looks green. Confirm by testing a real login as a user with totpEnabled = true and check you land on the code-entry screen.
Route precedence. The UI's route prefix can collide with Scheb's 2fa_login routes or your app's. If debug:router shows the same URI mapped twice, the last one loaded wins and you can end up rendering the wrong template. Import the bundle's routes last in routes.yaml, or scope with prefix:.
Template shadowing on upgrade. Symfony template overrides are resolved by path order. On a Symfony major bump, this bundle's templates can shadow or be shadowed by new Scheb defaults, silently changing the enrollment screen. Test the full enrollment flow after every Symfony/Scheb upgrade — not just "does the app boot." Budget for this recurring cost; the package has no CI matrix and won't catch you.
Secrets in logs and telemetry. The provisioning URI contains the shared secret. Logging it once — in an error handler, an APM span, a Sentry breadcrumb — fully compromises the factor. Configure log redaction explicitly for the otpauth:// URI and for any request parameter or entity field named totpSecret/secret. Test it: temporarily enable debug logging during enrollment in a dev environment and grep the log for the secret. If you find it, fix it before you ship.
Clock drift tolerance is Scheb's knob, not this bundle's. If users report "code rejected," look at TotpConfiguration (time step, digits, algorithm) and Scheb's window setting. The UI layer just forwards the code. Don't debug it in the templates.
CSRF on the enrollment POST. Check that the bundle's form types include a CSRF token field and that your framework.csrf_protection is enabled. With 3 stars and no dependents, this is exactly the kind of thing that has not been reviewed by a security community. Inspect the form type and controller in vendor/svc/totp-bundle/src/ before assuming it's covered.
No security disclosure path. The README won't link to a SECURITY.md, and there's no advisory feed for this package specifically. Subscribe to scheb/2fa-bundle advisories — the actual vulnerabilities will land there. If you find a bug in this UI package, the fastest fix is a PR or a local fork; don't wait on a maintainer response.
Fork and pin if you're going to depend on it. Pin to a commit SHA, keep your patches as a branch, and be prepared to rebase on Symfony upgrades. This is a "vendored starting point," not a maintained dependency. Treating it otherwise will bite you at the worst possible moment — a Symfony major release during an unrelated upgrade cycle.
Debugging checklist when enrollment breaks:
debug:router | grep -iE 'totp|2fa' — are the expected routes present and unique?debug:twig — is the template path resolving to @SvcTotpBundle/... or the app's override?bin/console debug:container | grep -i totp — did the TotpAuthenticator service register?isTotpAuthenticationEnabled() returning the right thing at the moment the firewall runs? (Dump it temporarily; false here means Scheb skips the prompt entirely.)scheb_two_factor.totp.template points at the file you think it does.Extension points that actually exist: form type overrides via form_themes, template overrides via standard bundle path resolution, and route overrides via resource: + prefix:. Service overrides via compiler pass are possible but fragile — prefer decoration over override, and prefer "don't touch it" over both.
Effort honesty for a Laravel team: half a day to install in Symfony, effectively a sprint to port to Laravel (at which point you're building your own UI and shouldn't ship this as a dependency). The port effort is not the learning curve — it's rebuilding the screens.
How can I help you explore Laravel packages today?