A self-hosted TOTP (Time-based One-Time Password) manager built with PHP. Secret keys are AES-256-GCM encrypted on the server and never transmitted to the client. OTP codes are generated server-side and can be shared with teammates without ever exposing the underlying secrets.
- Server-side code generation — secrets are decrypted in memory only at generation time and never sent to the browser
- AES-256-GCM encryption — every secret is encrypted at rest with a unique 96-bit nonce per record
- Multiple login methods — OAuth 2.0/OIDC via Google, Microsoft, GitHub, or Keycloak; passwordless magic links via email
- Token sharing — share individual OTP profiles with colleagues by email; they can view codes (or optionally edit settings) without ever seeing the secret
- QR code import — paste or drag an
otpauth://QR image directly in the browser to auto-fill all token fields - Hide mode — mask a token's code until clicked; it reveals for 10 seconds then hides itself again
- Full RFC 6238 support — SHA1, SHA256, SHA512 · 6, 8, or 10 digit codes · configurable time periods
- Icon & colour tagging — assign any Font Awesome brand or solid icon and one of 16 accent colours to each token for quick visual identification
- Google Authenticator bulk import - import your Google Authenticator accounts via the otpauth-migration QR images
- Export profiles — download your TOTP profiles (owned or with edit rights) as a CSV file containing decrypted seeds, compatible with Molto2 bulk import feature
- PHP 8.1+
- MySQL 5.7+ or MariaDB 10.3+
- Apache with
mod_rewriteenabled (AllowOverride All) - A MailerSend account (for magic link emails)
- OpenSSL PHP extension (for AES-256-GCM)
git clone https://github.com/token2/TOTPvault.git
cd totpvaultCREATE DATABASE totpvault CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;Then import the schema:
mysql -u youruser -p totpvault < schema.sqlEdit config/config.php and fill in all values. See Configuration below.
Fresh installs that import schema.sql already include every required table. Existing installations should apply any SQL files in migrations/ that have not been run yet:
mysql -u youruser -p totpvault < migrations/001_add_oauth_identities.sqlchmod 750 config/
Point your virtual host document root to the project directory. All requests are routed through index.php via .htaccess. The config/, src/, sessions/, and templates/ directories are individually protected by .htaccess rules that deny direct HTTP access.
config/config.php must return a PHP array. All keys are required unless marked optional.
<?php
return [
// ── Application ────────────────────────────────────────────────────────
'app_url' => 'https://yourdomain.com', // No trailing slash
// ── Database ───────────────────────────────────────────────────────────
'db' => [
'host' => 'localhost',
'port' => 3306,
'dbname' => 'totpvault',
'charset' => 'utf8mb4',
'user' => 'db_user',
'password' => 'db_password',
],
// ── Encryption ─────────────────────────────────────────────────────────
// Must be exactly 32 bytes. Generate a safe value with:
// php -r "echo base64_encode(random_bytes(32)) . PHP_EOL;"
'encryption_key' => 'base64-encoded-32-byte-key',
// ── Session ────────────────────────────────────────────────────────────
'session' => [
'cookie_name' => 'totpvault_session',
'lifetime' => 86400, // seconds (default: 24 h)
],
// ── Mail (MailerSend) ──────────────────────────────────────────────────
'mail' => [
'mailersend_key' => 'your-mailersend-api-key',
'from_email' => 'noreply@yourdomain.com',
'from_name' => 'TOTPVault',
],
// ── OAuth providers (remove any you don't need) ────────────────────────
'oauth' => [
'google' => [
'client_id' => '',
'client_secret' => '',
'redirect_uri' => 'https://yourdomain.com/auth/callback/google',
'scope' => 'openid email profile',
'auth_url' => 'https://accounts.google.com/o/oauth2/v2/auth',
'token_url' => 'https://oauth2.googleapis.com/token',
'userinfo_url' => 'https://openidconnect.googleapis.com/v1/userinfo',
],
'microsoft' => [
'client_id' => '',
'client_secret' => '',
'redirect_uri' => 'https://yourdomain.com/auth/callback/microsoft',
'scope' => 'openid email profile',
'auth_url' => 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
'token_url' => 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
'userinfo_url' => 'https://graph.microsoft.com/v1.0/me',
],
'github' => [
'client_id' => '',
'client_secret' => '',
'redirect_uri' => 'https://yourdomain.com/auth/callback/github',
'scope' => 'user:email',
'auth_url' => 'https://github.com/login/oauth/authorize',
'token_url' => 'https://github.com/login/oauth/access_token',
'userinfo_url' => 'https://api.github.com/user',
],
'keycloak' => [
'client_id' => '',
'client_secret' => '',
'base_url' => 'https://keycloak.example.com',
'realm' => 'totpvault',
'redirect_uri' => 'https://yourdomain.com/auth/callback/keycloak',
'scope' => 'openid email profile',
],
],
];No password required. The user enters their email address and receives a time-limited sign-in link delivered via MailerSend.
- Tokens are 32 random bytes; only the SHA-256 hash is stored in the database — the raw token only ever exists in the email
- Links expire after 15 minutes
- Rate-limited to 3 requests per email per 10 minutes
- Each new request invalidates all previous unused links for that address
- If the email has never been seen before, a new account is created automatically on first use
- Any token shares pending for that email are linked to the new account immediately
One-click sign-in via a third-party identity provider. The OAuth state parameter is verified on callback to prevent CSRF. Four providers are supported out of the box:
| Provider | Notes |
|---|---|
| OpenID Connect; fetches name, email, and profile picture | |
| Microsoft | Microsoft Graph; supports personal and work/school accounts |
| GitHub | Fetches the primary verified email separately via /user/emails since the profile endpoint may return null |
| Keycloak | OpenID Connect discovery; maps sub, email, name / preferred_username, and optional picture claims |
OAuth identities are stored in the oauth_identities table as (provider, provider_id) pairs linked to users.id. A user who signs in with Google and later via magic link to the same email address shares the same account row.
Each token belongs to one user and stores the following fields:
| Field | Values | Default |
|---|---|---|
name |
Any string | — |
issuer |
Any string (optional) | — |
secret_encrypted |
AES-256-GCM ciphertext | — |
algorithm |
SHA1, SHA256, SHA512 |
SHA1 |
digits |
6, 8, 10 |
6 |
period |
15–300 seconds | 30 |
color |
Hex colour from the palette | #6366f1 |
icon |
Font Awesome class name | fa-shield-halved |
hide_code |
0 or 1 |
0 |
TOTP codes are generated entirely in PHP. The HMAC is computed with the chosen algorithm, dynamic truncation extracts a 4-byte slice, and the result is taken modulo 10^digits and zero-padded to the required length:
counter = floor(unix_timestamp / period)
hmac = HMAC-{algo}(base32_decode(secret), pack('J', counter))
offset = last_byte(hmac) & 0x0F
code = (hmac[offset..offset+3] & 0x7FFFFFFF) % 10^digits
Supported algorithms:
| Algorithm | HMAC output | Compatibility |
|---|---|---|
SHA1 |
20 bytes | Universal — Google Authenticator, Authy, hardware tokens |
SHA256 |
32 bytes | Supported by some hardware tokens and newer apps |
SHA512 |
64 bytes | Maximum security; fewer app implementations |
Codes are visually grouped with a space for readability:
| Digits | Format |
|---|---|
| 6 | 123 456 |
| 8 | 1234 5678 |
| 10 | 123 456 789 0 |
When hide_code = 1, the code is replaced with dots (e.g. ••• •••) on the dashboard. Clicking the card or the Reveal button displays the code for 10 seconds, then it re-hides automatically. The raw code is held only in a JS dataset attribute during the reveal window and is never rendered to the DOM at rest.
The built-in generator creates secrets server-side using random_bytes() and Base32-encodes them to a 32-character string (160 bits of entropy).
The browser-side QR scanner uses jsQR to decode an otpauth://totp/ URI from a dropped, uploaded, or clipboard-pasted image — entirely client-side with no image data sent to the server. All parsed fields (secret, issuer, algorithm, digits, period) are populated directly into the form.
/tools/import-google-auth is a standalone import page for migrating from Google Authenticator.
Google Authenticator's export QR codes use the otpauth-migration://offline?data=... format, which encodes a Protocol Buffer binary payload containing multiple accounts. This is different from a standard otpauth:// URI and cannot be decoded by the regular QR scanner.
How it works:
- In Google Authenticator, tap ⋮ → Transfer accounts → Export accounts, then screenshot the QR code(s)
- Drop or paste each screenshot into the import page
- jsQR decodes the QR client-side and extracts the
dataparameter - The base64-encoded protobuf is sent to the server, where a hand-rolled PHP decoder parses it without any external libraries
- All found accounts are shown in a review table — select which ones to import
- Each selected token is saved via the normal
POST /api/profilesendpoint
The protobuf decoder handles the fixed MigrationPayload schema, converting raw secret bytes to Base32 and mapping Google's internal algorithm/digit/type enums to their standard values. HOTP tokens are detected and excluded (not supported). If a QR contains many accounts, Google Authenticator may split the export across multiple QR codes — each can be processed in sequence on the same page before clicking Import.
Each profile can be tagged with one of 16 preset accent colours used for the card indicator dot and icon tint:
| Name | Hex | Name | Hex | |
|---|---|---|---|---|
| Indigo | #6366f1 |
Sky | #0ea5e9 |
|
| Blue | #3b82f6 |
Teal | #14b8a6 |
|
| Cyan | #06b6d4 |
Green | #22c55e |
|
| Emerald | #10b981 |
Yellow | #eab308 |
|
| Lime | #84cc16 |
Rose | #f43f5e |
|
| Amber | #f59e0b |
Slate | #6b7280 |
|
| Orange | #f97316 |
Violet | #8b5cf6 |
|
| Red | #ef4444 |
Pink | #ec4899 |
Icons use Font Awesome 6 and are split into two groups. The icon list is defined once in PHP and injected into the JavaScript via json_encode() — no duplication in the codebase.
Brands (fa-brands) — service and platform logos:
github · google · microsoft · apple · amazon · facebook · twitter · instagram · linkedin · slack · discord · telegram · whatsapp · dropbox · spotify · twitch · youtube · reddit · gitlab · bitbucket · docker · aws · cloudflare · digital-ocean · stripe · paypal · shopify · wordpress · jenkins · jira · confluence · trello · npm · node · react · vuejs · angular · laravel · php · python · java · swift · android · windows · linux · ubuntu · firefox · chrome · safari · steam · playstation · xbox
General (fa-solid) — generic categories:
shield-halved · key · lock · lock-open · user · users · building · globe · server · database · cloud · code · terminal · mobile · laptop · desktop · wifi · envelope · bell · star · bolt · fire · gear · wrench · briefcase · chart-bar · credit-card · wallet · shop · robot · microchip
An owner can share any profile with any email address. The recipient can view live OTP codes from their own dashboard without access to the encrypted secret.
- View-only (default) — recipient sees the code but cannot modify the token
- Can edit — recipient can update name, settings, icon, and colour, but still cannot read the secret
If the invited email does not yet have an account, the share is stored as pending and activates automatically the first time that address signs in. Owners can revoke shares at any time.
| Concern | Approach |
|---|---|
| Secret storage | AES-256-GCM with a random 96-bit nonce per encryption; IV + auth tag + ciphertext stored as a single base64 blob |
| Secret transmission | Never sent to the client; decrypted in PHP memory only during code generation |
| CSRF | All state-changing API endpoints verify a session-bound token sent as X-CSRF-Token header |
| Session cookies | HttpOnly, SameSite=Lax, Secure (when HTTPS is present) |
| OAuth state | Random 32-byte hex state verified on callback |
| Magic link tokens | 32 random bytes; only the SHA-256 hash is stored in the database |
| Rate limiting | Magic link requests capped at 3 per email per 10 minutes |
| Directory access | config/, src/, sessions/, templates/ all deny direct HTTP access via .htaccess |
config.php |
Excluded from version control via .gitignore |
The canonical schema lives in schema.sql. Docker deployments use docker/init-db.sql on first database startup.
Core tables:
| Table | Purpose |
|---|---|
users |
Application account profile keyed by email |
oauth_identities |
Generic OAuth/OIDC identities keyed by (provider, provider_id) and linked to users.id |
magic_links |
Hashed passwordless login tokens |
otp_profiles |
Encrypted TOTP profiles |
profile_shares |
Token sharing grants and pending shares |
├── config/
│ ├── config.php # Your local config — not committed
│ ├── config.docker.php # Docker runtime config from environment variables
│ └── .htaccess # Deny all HTTP access
├── docker/
│ ├── 000-default.conf # Apache vhost for the app container
│ ├── init-db.sql # Docker database initialization schema
│ └── keycloak/
│ └── totpvault-realm.json # Local Keycloak test realm import
├── sessions/ # PHP session files — not committed
├── migrations/
│ └── 001_add_oauth_identities.sql # Upgrade existing installs for generic OAuth identities
├── src/
│ ├── Auth.php # Session management, OAuth user lookup
│ ├── Crypto.php # AES-256-GCM encrypt / decrypt
│ ├── Database.php # PDO singleton
│ ├── MagicLink.php # Passwordless email login
│ ├── OAuthP.php # OAuth 2.0/OIDC — Google / Microsoft / GitHub / Keycloak
│ ├── Profile.php # TOTP profile CRUD and sharing
│ └── TOTP.php # RFC 6238 code generation and Base32
├── templates/
│ ├── layout.php # Shared HTML shell and CSS variables
│ ├── landing.php # Public marketing / login page
│ ├── dashboard.php # Authenticated token manager
│ └── 404.php
├── tools/
│ └── import-google-auth.php # Google Authenticator migration QR importer
├── Dockerfile # PHP/Apache application image
├── docker-compose.yml # App and MariaDB services
├── docker-compose.keycloak.yml # Optional local Keycloak test override
├── Makefile # Docker Compose shortcuts
├── favicon.png
├── index.php # Front controller and router
├── schema.sql # Database schema
└── .htaccess # Rewrites all requests to index.php
A complete Docker setup is included for local or self-hosted deployment. It runs the PHP app under Apache and MariaDB in two containers behind a private bridge network.
# 1. Clone and enter the project
git clone https://github.com/token2/TOTPvault.git
cd TOTPVault
# 2. Create your environment file — then configure a login method
cp .env.example .env
# 3. Start everything
docker compose up -d --build
# 4. Open the app
open http://localhost:8080Production deployment: Before going live, open
.envand replace the three values marked⚠ CHANGE ME:
ENCRYPTION_KEY— generate a fresh one:php -r "echo base64_encode(random_bytes(32)) . PHP_EOL;"DB_PASSWORD— use a strong unique passwordDB_ROOT_PASSWORD— use a strong unique password
To sign in, configure at least one login method in .env: MailerSend for magic links, or Google, Microsoft, GitHub, or Keycloak OAuth/OIDC credentials.
| Command | Description |
|---|---|
make up |
Build and start all services |
make down |
Stop all services |
make logs |
Follow application logs |
make rebuild |
Full rebuild (no cache) and restart |
make shell |
Bash shell inside the app container |
make db-shell |
MariaDB shell inside the database container |
# View logs
docker compose logs -f app
# Stop everything
docker compose down
# Reset database (destroys all data!)
docker compose down -v| Volume | Purpose |
|---|---|
totpvault-db-data |
MariaDB database files — survives container rebuilds |
totpvault-sessions |
PHP session files — keeps users logged in across restarts |
totpvault-keycloak-data |
Optional local Keycloak test server data when the keycloak profile is enabled |
To list volumes: docker volume ls | grep totpvault
config/config.docker.phpis mounted asconfig/config.phpinside the container (read-only).- All settings are read from environment variables defined in
.env. - OAuth redirect URIs are derived automatically from
APP_URL. - The original
config/config.phpon the host is not modified and remains usable for non-Docker deployments. - Never commit
.envor real secrets to Git. The.gitignoreexcludes.envby default.
Google, Microsoft, GitHub, and Keycloak sign-in require OAuth/OIDC application credentials from each provider. For local Docker deployment, keep:
APP_URL=http://localhost:8080Then register these callback URLs with the providers you want to enable:
| Provider | Setup page | Local callback URL | .env values |
|---|---|---|---|
| Google Cloud Console — Credentials | http://localhost:8080/auth/callback/google |
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET |
|
| Microsoft | Microsoft Entra — App registrations | http://localhost:8080/auth/callback/microsoft |
MICROSOFT_CLIENT_ID, MICROSOFT_CLIENT_SECRET |
| GitHub | GitHub Developer Settings — OAuth Apps | http://localhost:8080/auth/callback/github |
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET |
| Keycloak | Your Keycloak admin console | http://localhost:8080/auth/callback/keycloak |
KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET, KEYCLOAK_BASE_URL, KEYCLOAK_REALM |
For Keycloak, create an OpenID Connect client in your realm, set the valid redirect URI to the callback URL above, and set KEYCLOAK_BASE_URL to the Keycloak server root, for example https://sso.example.com. TOTPVault uses KEYCLOAK_BASE_URL and KEYCLOAK_REALM to read the realm's /.well-known/openid-configuration discovery document.
The Docker Compose setup includes an optional local Keycloak test server on http://localhost:8081. To enable it for local testing, include the Keycloak override file and start Compose with the keycloak profile:
docker compose -f docker-compose.yml -f docker-compose.keycloak.yml --profile keycloak up -d --buildThe override file supplies local KEYCLOAK_* values to the app. On first startup, Keycloak imports docker/keycloak/totpvault-realm.json with:
| Setting | Value |
|---|---|
| Admin console | http://localhost:8081 |
| Admin username | admin |
| Admin password | admin |
| Realm | totpvault |
| Client ID | totpvault |
| Client secret | totpvault-dev-secret |
| Test user | totpuser |
| Test password | totpuser |
These defaults are for local testing only. For production, use your own Keycloak deployment, change the client secret, set KEYCLOAK_BASE_URL to the public Keycloak URL, and set KEYCLOAK_INTERNAL_BASE_URL only if the PHP container needs a different internal network URL for token and userinfo calls.
After updating .env, rebuild/restart the app:
docker compose up -d --buildFor production, replace http://localhost:8080 with your public APP_URL and register matching HTTPS callback URLs, for example https://yourdomain.com/auth/callback/google.
On first startup, MariaDB automatically runs docker/init-db.sql to create all tables. This happens only when the data volume is empty. To re-initialise:
docker compose down -v # removes the data volume
docker compose up -d --buildApp cannot connect to database
- Ensure
DB_HOST=dbin.env(must match the Compose service name). - Check that the
dbcontainer is healthy:docker compose ps - Verify
DB_USER,DB_PASSWORD, andDB_NAMEmatch between the app and db service. - The app waits for the db healthcheck before starting (
depends_on: condition: service_healthy). - If you use Podman Compose and see
getaddrinfo for db failed, verify service DNS inside the app container:podman exec totpvault-app getent hosts db - If
dbdoes not resolve in Podman Compose, setDB_HOST=totpvault-db, restart the stack, and useKEYCLOAK_INTERNAL_BASE_URL=http://totpvault-keycloak:8080for the optional local Keycloak container.
Access denied for user — after changing .env passwords
- MariaDB only applies
MYSQL_USER/MYSQL_PASSWORDon a fresh (empty) data volume. If the volume already exists with different credentials, the new values are ignored. - Fix: destroy the volume and restart so MariaDB re-initialises:
docker compose down -v # ⚠ deletes all database data docker compose up -d --build
Schema not imported
- Init scripts only run on a fresh data volume. If you changed the schema, reset with
docker compose down -vfirst. - Check MariaDB logs:
docker compose logs db | head -100
.htaccess rewrite not working
- Verify
mod_rewriteis enabled:docker compose exec app apache2ctl -M | grep rewrite - The Dockerfile enables it automatically. If you see 404 errors for routes like
/dashboard, check thatAllowOverride Allis set indocker/000-default.conf.
Missing encryption key
ENCRYPTION_KEYmust be set in.env. Generate it with:php -r "echo base64_encode(random_bytes(32)) . PHP_EOL;"- If the key changes after tokens have been stored, existing encrypted secrets become permanently unreadable.
MailerSend not configured
- Magic-link login requires a valid
MAILERSEND_KEY. Without it, email sending will silently fail. - Sign up at mailersend.com, verify a domain, and create an API token.
- Set
MAIL_FROM_EMAILto an address on your verified domain.
Check out the live demo here: Live Demo
GPL