2026-03-08 19:22:01 +01:00
2026-03-05 17:05:20 +01:00
2026-03-05 17:05:20 +01:00
2026-03-08 19:18:54 +01:00
2026-03-08 19:18:17 +01:00
2026-03-05 17:05:20 +01:00
2026-03-08 19:18:39 +01:00
2026-03-05 12:09:44 +01:00
2026-03-08 19:22:01 +01:00

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_rewrite enabled (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

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
Google 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 15300 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:

  1. In Google Authenticator, tap ⋮ → Transfer accountsExport accounts, then screenshot the QR code(s)
  2. Drop or paste each screenshot into the import page
  3. jsQR decodes the QR client-side and extracts the data parameter
  4. The base64-encoded protobuf is sent to the server, where a hand-rolled PHP decoder parses it without any external libraries
  5. All found accounts are shown in a review table — select which ones to import
  6. Each selected token is saved via the normal POST /api/profiles endpoint

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

image image image image

License

GPL

Description
No description provided
Readme GPL-3.0 113 KiB
Languages
PHP 100%