Skip to content

token2/TOTPVault

Repository files navigation

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/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

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. Existing installations only: apply migrations

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.sql

5. Set directory permissions

chmod 750 config/

6. 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',
        ],

        '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',
        ],

    ],
];

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. Four 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
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.


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:

  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

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

Project structure

├── 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

Docker Compose Deployment

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.

Quick start

# 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:8080

Production deployment: Before going live, open .env and 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 password
  • DB_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.

Makefile shortcuts

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

Common commands

# View logs
docker compose logs -f app

# Stop everything
docker compose down

# Reset database (destroys all data!)
docker compose down -v

Persistent data

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

How configuration works

  • config/config.docker.php is mounted as config/config.php inside 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.php on the host is not modified and remains usable for non-Docker deployments.
  • Never commit .env or real secrets to Git. The .gitignore excludes .env by default.

OAuth provider setup

Google, Microsoft, GitHub, and Keycloak sign-in require OAuth/OIDC application credentials from each provider. For local Docker deployment, keep:

APP_URL=http://localhost:8080

Then register these callback URLs with the providers you want to enable:

Provider Setup page Local callback URL .env values
Google 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 --build

The 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 --build

For production, replace http://localhost:8080 with your public APP_URL and register matching HTTPS callback URLs, for example https://yourdomain.com/auth/callback/google.

Database initialisation

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 --build

Troubleshooting

App cannot connect to database

  • Ensure DB_HOST=db in .env (must match the Compose service name).
  • Check that the db container is healthy: docker compose ps
  • Verify DB_USER, DB_PASSWORD, and DB_NAME match 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 db does not resolve in Podman Compose, set DB_HOST=totpvault-db, restart the stack, and use KEYCLOAK_INTERNAL_BASE_URL=http://totpvault-keycloak:8080 for the optional local Keycloak container.

Access denied for user — after changing .env passwords

  • MariaDB only applies MYSQL_USER/MYSQL_PASSWORD on 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 -v first.
  • Check MariaDB logs: docker compose logs db | head -100

.htaccess rewrite not working

  • Verify mod_rewrite is enabled: docker compose exec app apache2ctl -M | grep rewrite
  • The Dockerfile enables it automatically. If you see 404 errors for routes like /dashboard, check that AllowOverride All is set in docker/000-default.conf.

Missing encryption key

  • ENCRYPTION_KEY must 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_EMAIL to an address on your verified domain.

Demo

Check out the live demo here: Live Demo

Screenshots

image image image image

License

GPL

About

A self-hosted TOTP manager built with PHP

Resources

License

Security policy

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors