TOTPVault
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.
Features
- 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 via Google, Microsoft, or GitHub; 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
Requirements
- 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)
Installation
1. Clone the repository
git clone https://github.com/token2/TOTPvault.git
cd totpvault
2. Create the database
CREATE DATABASE totpvault CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
Then import the schema:
mysql -u youruser -p totpvault < schema.sql
3. Configure the application
Edit config/config.php and fill in all values. See Configuration below.
4. Set directory permissions
chmod 750 config/
5. Web server
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.
Configuration
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',
],
],
];
Login methods
Magic link (email)
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
OAuth 2.0
One-click sign-in via a third-party identity provider. The OAuth state parameter is verified on callback to prevent CSRF. Three 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 |
Each provider stores its own provider_id column in the users table. A user who signs in with Google and later via magic link to the same email address shares the same account row.
TOTP profiles
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 |
Code generation (RFC 6238)
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 |
Code display format
Codes are visually grouped with a space for readability:
| Digits | Format |
|---|---|
| 6 | 123 456 |
| 8 | 1234 5678 |
| 10 | 123 456 789 0 |
Hide mode
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.
Secret generation
The built-in generator creates secrets server-side using random_bytes() and Base32-encodes them to a 32-character string (160 bits of entropy).
QR code import
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.
Google Authenticator bulk import
/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.
Colours
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
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
Token sharing
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.
Security notes
| 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 |
Database schema
--
-- Table structure for table `magic_links`
--
CREATE TABLE `magic_links` (
`id` int(11) NOT NULL,
`email` varchar(255) NOT NULL,
`token_hash` varchar(64) NOT NULL,
`used` tinyint(1) NOT NULL DEFAULT 0,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
`expires_at` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- --------------------------------------------------------
--
-- Table structure for table `otp_profiles`
--
CREATE TABLE `otp_profiles` (
`id` int(11) NOT NULL,
`user_id` int(11) NOT NULL,
`name` varchar(255) NOT NULL,
`issuer` varchar(255) DEFAULT '',
`secret_encrypted` text NOT NULL COMMENT 'AES-256-GCM encrypted base32 secret',
`algorithm` enum('SHA1','SHA256','SHA512') DEFAULT 'SHA1',
`digits` tinyint(4) DEFAULT 6 COMMENT '6, 8 or 10',
`period` smallint(6) DEFAULT 30 COMMENT 'seconds',
`color` varchar(7) DEFAULT '#6366f1',
`icon` varchar(50) DEFAULT 'shield',
`hide_code` tinyint(1) NOT NULL DEFAULT 0,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-- --------------------------------------------------------
--
-- Table structure for table `profile_shares`
--
CREATE TABLE `profile_shares` (
`id` int(11) NOT NULL,
`profile_id` int(11) NOT NULL,
`shared_by_user_id` int(11) NOT NULL,
`shared_with_email` varchar(255) NOT NULL,
`shared_with_user_id` int(11) DEFAULT NULL,
`can_edit` tinyint(1) DEFAULT 0,
`created_at` timestamp NOT NULL DEFAULT current_timestamp()
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-- --------------------------------------------------------
--
-- Table structure for table `users`
--
CREATE TABLE `users` (
`id` int(11) NOT NULL,
`email` varchar(255) NOT NULL,
`name` varchar(255) DEFAULT NULL,
`avatar_url` varchar(500) DEFAULT NULL,
`google_id` varchar(255) DEFAULT NULL,
`microsoft_id` varchar(255) DEFAULT NULL,
`github_id` varchar(255) DEFAULT NULL,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
Project structure
├── config/
│ ├── config.php # Your local config — not committed
│ └── .htaccess # Deny all HTTP access
├── sessions/ # PHP session files — not committed
├── 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 — Google / Microsoft / GitHub
│ ├── 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
├── favicon.png
├── index.php # Front controller and router
├── schema.sql # Database schema
└── .htaccess # Rewrites all requests to index.php
Demo
Check out the live demo here: Live Demo
Screenshots
License
GPL