Secret Vault and Encryption
Your membership plugin ends up holding keys to many different kingdoms:
- The CRM API keys that sync your members
- OAuth client secrets
- Webhook signing keys
- The credentials for your email and CAPTCHA services
- ...and more
This page explains where those secrets live, how they're encrypted, and what you need to do to keep them safe and recoverable. A note on names: the plugin documented here is sold as Torii and as Memberium Universal (formerly Memberium for HighLevel), both by Web Power and Light. They are the same software, and everything below applies equally to both.
It's written for two readers at once. If you run the site, the parts that matter to you are the backup warning and the recovery runbook. If you are the agency dev or the security reviewer, the middle sections give you the mechanics: algorithms, constants, precedence rules, and the failure modes the plugin handles on its own.
The problem it solves
The usual place for plugin settings in WordPress is the wp_options table in the database. That's fine for most settings and wrong for secrets. The biggest threat is SQL injection: when an attacker gets arbitrary queries running against your database, one of the first things they hunt for is stored credentials, Stripe keys, Amazon AWS keys, anything with a billing account or broad API permissions behind it. A plugin that stores its API keys in plaintext hands those over in the same breach. Database contents travel in quieter ways too: nightly backups, SQL dumps taken before a migration, staging copies of production, export tools, the odd compromised admin account. Every one of those copies would carry your CRM credentials in the clear if the plugin stored them the ordinary way.
Torii refuses to do that. Secrets are kept in two places, neither of which is a plaintext database row:
- The Secret Vault. A small PHP file the plugin generates and maintains at
wp-content/mu-plugins/wpal-torii-vault.php. It defines your secrets as PHP constants, so they exist only on the filesystem and are loaded into memory by WordPress on every request, before regular plugins run. - Encrypted database storage. Anything not suited to a constant is stored in the database only as ciphertext, sealed under a master encryption key that itself lives in the vault file, never in the database.
Both layers rest on the same foundation: one master key, generated on your server at install time, which never leaves your site.
Where secrets actually live
| Secret | Where it lives | Who can read it |
|---|---|---|
Master encryption key (WPAL_TORII_ENCRYPTION_KEY) |
The vault file, or wp-config.php on hardened sites |
Anything with filesystem access |
| Connector API keys and OAuth secrets | Vault file constants when the directory is writable; encrypted database fields otherwise | The connector that owns them |
| Other credential-type settings (CAPTCHA secrets, webhook keys, and similar) | Database, encrypted before saving | The module that owns them |
| Values saved before any key existed | Database, plaintext, until re-saved | Anything that can read the database |
That last row is the one situation to avoid, and it has a visible symptom covered under Checking vault health. Until a master key exists, the plugin can't encrypt, so it stores what you give it as-is and tells you so. Once a key is in place, new saves are encrypted; values saved during the gap stay plaintext until the next time you save them.
If you keep the vault file writable, you never see the fallback row: connector keys move into the vault automatically as you save them, and the database keeps only encrypted values.
How the encryption works
The encryption comes from libsodium, a cryptographic library that ships by default with modern PHP builds. The algorithm is XChaCha20-Poly1305, an authenticated cipher: when a value is decrypted, the plugin first checks that the encrypted text and its metadata haven't been changed since encryption. If anything doesn't match, decryption refuses to return anything at all rather than returning damaged data.
An encrypted value stored in the database looks like this:
torii_encrypted:{"v":1,"alg":"xchacha20poly1305","nonce":"…","ct":"…"}
The nonce and ct fields are the per-message random nonce and the ciphertext, both base64. There's no key material in the payload. The prefix lets the plugin tell at a glance whether any stored value is already encrypted.
Two design details are worth knowing even if you never touch the code:
Each field gets its own derived key. The master key is never used to encrypt directly. For every context, say the Keap connector's API key field, a subkey is derived from the master key with HKDF-SHA256. The ciphertext is also bound to its context: the context name is authenticated data, so a value copied from one settings field into another, or from one site's database into another field on the same site, won't decrypt. A secret is pinned to where it was written.
Decryption results are cached. To avoid decrypting the same value on every request, plaintext is cached in the WordPress object cache for an hour after first use. If your object cache backend persists to disk, say Redis with AOF or RDB snapshots enabled, that means plaintext secrets can end up in the Redis persistence file. If that's a concern for your threat model, define WPAL_TORII_SECRETS_NO_OBJECT_CACHE as covered in the constants table, and decrypted values stay in request memory only.
In admin screens, secrets are never displayed whole. Fields that hold a saved secret show a masked form like ***f9a2, with the last few characters visible so you can confirm you're looking at the right key.
Why it's built this way
The design follows from the threats above, and most of the decisions have a specific reason behind them:
- A mu-plugin file rather than the database. The filesystem is the one place WordPress already trusts with secrets;
wp-config.phplives there for exactly this reason. Moving secrets out of the database takes them out of database backups, dumps, and staging copies. Regular plugins were rejected because they can be deactivated; mu-plugins load unconditionally on every request, can't be switched off from the admin screens, and load earlier than regular plugins. - Generated rather than hand-edited. The file is written by the plugin in a single atomic operation, so there's no window where a half-written file is live, and no expectation that you will ever edit it by hand.
- The key is generated once, never rotated automatically. Every encrypted value in your database depends on the master key. Regenerating it would silently orphan every secret already stored. So generation happens exactly once, at install, and the plugin will never replace an existing key on its own.
- Existing definitions always win. Every constant in the vault file is written as a guarded define:
defined( 'X' ) || define( 'X', 'value' );. If a constant is already defined, inwp-config.phpor the environment, the vault yields to it. You can always override the vault from config, and the vault can never cause a duplicate-definition fatal error. - Atomic, verified writes. Saving the vault writes to a temporary file first, reads it back and compares it byte for byte, then renames it over the live file. The temp file's name ends in
.phpon purpose: if a request arrives for it during the short write window, the web server executes the guarded defines harmlessly instead of serving the file as source code, which would leak the key. The live file is never deleted or truncated at any point in the process. - Nothing leaves the site. The keys are never transmitted anywhere, including to us. The plugin's only outbound calls are the license and update checks described in Data Handling.
How the vault installs itself
You don't need to create the vault file, or carefully edit your wp-config.php. The plugin does it for you, at activation and on later admin page loads as a safety net:
- If a valid vault file already exists, nothing happens. The installer is a no-op by design; it will never overwrite an installed vault.
- If the file is missing and
wp-content/mu-plugins/is writable, the plugin creates the directory if needed, generates a fresh 256-bit key (64 hex characters), and writes the vault file in one atomic operation, described above. - If
WPAL_TORII_ENCRYPTION_KEYis already defined, fromwp-config.phpor the environment, that value is recorded into the vault instead of generating a new key. Encryption works either way; the file and the runtime stay in agreement. - If an existing file is present but corrupt, say a truncated upload or a bad merge, the plugin attempts a repair. It first tries to recover the key from the damaged file so existing encrypted data stays readable, and it records what it did, so you get a one-time notice in the admin explaining the repair. One case is deliberately left alone: if the key constant is already defined at runtime, encryption is working, and a structurally imperfect file is reported through diagnostics rather than rewritten.
Connector API keys are added to the vault as you save them in each connector's settings. Fields managed by the vault show a managed by Secret Vault placeholder instead of their values, and each one has a stable constant name of the form TORII_<CONNECTOR>_<FIELD>, for example TORII_KEAP_API_KEY. The list of constants the vault manages is itself recorded inside the file, in the WPAL_TORII_VAULT_KEYS manifest, so disabling a connector doesn't cause its constants to be dropped on the next save.
Read-only and hardened sites
Some deployments make wp-content immutable: sites that deploy code from git, containers where the web root is a baked image, hosts that lock down mu-plugins after hardening. The vault treats this as a legitimate configuration, not a failure.
When the directory isn't writable:
- The installer skips itself quietly. No errors on activation.
- An admin notice explains the situation: new secrets will be stored encrypted in the database instead of in the vault file. This is the same authenticated encryption; only the location changes.
- The master key comes from
wp-config.php. Define it there, ideally pulling the value from your deployment's secret store or environment rather than committing it:
defined( 'WPAL_TORII_ENCRYPTION_KEY' ) || define( 'WPAL_TORII_ENCRYPTION_KEY', getenv( 'TORII_ENCRYPTION_KEY' ) );
The precedence order never changes: a definition in wp-config.php (or the environment) always beats the vault file, which in turn supplies values the database can't. With the key defined in config, encrypted database storage works fully, and the only thing you lose is the convenience of constants for connector keys.
Teams that deploy wp-content from git sometimes ask whether to commit the vault file. It's ordinary PHP and commits cleanly, but it's a bag of live secrets: treat committing it the same way you would treat committing wp-config.php. Most teams should prefer the config-constant approach above and let secrets ride the database encrypted.
Checking vault health
The plugin reports its own status through WordPress Site Health, under Tools > Site Health in your admin. Three tests cover the vault:
| Test | What it checks |
|---|---|
| Encryption | The libsodium extension is loaded and a master key is configured |
| Vault file | The vault file exists, passes validation, and its key constant is actually loading |
| Config duplicates | No vault constant is also defined in wp-config.php |
Read them in that order. The encryption test is the one that matters; a passing result means secrets are being encrypted, regardless of where the key came from. The vault file test tells you whether the file specifically is healthy. The duplicates test is advisory but worth acting on, and is explained under Troubleshooting.
The Info tab of Site Health also carries a vault section with the diagnostic snapshot: the file's path, size, last modified time, permissions, whether it validated, why if it didn't, the key's format, whether the key defined at runtime matches the one in the file, and a record of any automatic repair. Nothing in the snapshot reveals key material; the key appears only as its length and format.
Backing up the vault
Concretely:
- Your filesystem backups must include
wp-content/mu-plugins/. Database-only backups capture the ciphertext but not the key that unlocks it. - Keep a second copy of the key itself, in your password manager or wherever you keep the credentials that guard other credentials. You need the 64-character hex value, from the
WPAL_TORII_ENCRYPTION_KEYline of the vault file. - If you define the key in
wp-config.phpinstead, the same rule applies to your config backups.
The vault file changes only when secrets change, so it's small and rarely written. An occasional manual copy alongside your database dumps is a reasonable belt-and-suspenders for a site that rarely touches connector settings.
When things go wrong
The failure signature is consistent across every scenario below: the key that encrypted your secrets isn't available, so decryption returns nothing. Connector screens show empty or masked fields, sync stops working, and Site Health's encryption test fails or the vault test names the problem. Work the scenario that matches yours.
The vault file was deleted. A cleanup script emptied mu-plugins, a deploy removed it, a host migration didn't copy it. Restore the file from your filesystem backup. If you have no file backup but recorded the key, define WPAL_TORII_ENCRYPTION_KEY in wp-config.php with that value; the plugin honors it, and everything encrypted becomes readable again. Encrypted data isn't damaged by the file being missing; it's simply locked until the key returns.
A restore brought back the wrong key. The database came from one backup, the vault file (or config) from another, and they belong to different eras. Symptoms: values decrypt to empty even though a key is defined. Find the key that matches the database: the vault file from the same backup generation as the database dump is the likeliest candidate. If it can't be found, the honest answer is that the encrypted secrets are gone; re-enter them from the CRMs and services themselves, where the originals live.
Moving to a new server. Copy the filesystem (including wp-content/mu-plugins/) and the database together. If the new server will use a read-only wp-content, instead define the key in the new server's wp-config.php before the first request that needs it. Either path preserves all encrypted data.
Staging copied the production database. The staging site can't decrypt anything because its wp-content never had the vault file. Two fixes: copy the vault file to staging as well, or define the production key in staging's wp-config.php. Both make the staging copy capable of reading your live CRM credentials, so restrict access to staging accordingly, and remember staging sites talk to the production CRM unless you deliberately configure otherwise.
The vault file is present but corrupt. Truncated upload, editor accident, bad merge. If the key constant is loading anyway (a define survived, or wp-config.php has it), encryption still works and Site Health simply reports the file's problem. If not, the plugin attempts its automatic repair on the next admin load, recovering the key from the damaged file when any valid fragment of the define survives. A notice tells you what was done and whether the key survived. Manual repair is the same as the deleted-file case: restore from backup or define the key in config.
Troubleshooting
A constant is defined in both wp-config.php and the vault. The config definition loads first and wins, so the vault's copy of that constant is dead weight: saving a new value in the connector screen updates the vault file, but the runtime keeps reading the old one from config. Site Health flags this as a critical finding and names the constants. Remove the duplicate define() lines from wp-config.php if you want the vault to manage those values.
The vault file exists but its constant isn't defined at runtime. Site Health words this as the mu-plugin possibly not loading. The usual cause: the file was moved into a subdirectory of mu-plugins. WordPress only auto-loads PHP files sitting directly in the mu-plugins root, not nested ones. Move the file back to wp-content/mu-plugins/wpal-torii-vault.php.
A just-saved secret isn't visible on the very next request. Some opcode cache configurations serve the previously compiled vault file for a few seconds. The plugin invalidates the cache entry after every write, so you shouldn't see this; if a host disables that invalidation, a PHP-FPM reload clears it. It's a seconds-scale annoyance, not data loss.
Secrets were saved before any key existed. Site Health warns that credentials will be stored as plaintext until a key is configured. Once the key exists, open each connector's settings and re-save; the values encrypt on save. Check the database or the Site Health warning if you want confirmation the plaintext window closed.
Rotating the encryption key
There's no built-in rotation yet. Rotation is genuinely harder than it sounds here: every stored secret must be decrypted under the old key and re-encrypted under the new one, caches of decrypted values must be flushed, and a single missed value turns into an unrecoverable secret. The internal hooks for it exist and a supported rotation tool is on the roadmap.
If you have a concrete reason to rotate today, a compromise exposure, a compliance clock, contact support rather than hand-editing the key. Changing the key value in the vault file without re-encrypting the database is indistinguishable from deleting the key: everything locks.
Configuration constants
| Constant | Purpose | Notes |
|---|---|---|
| WPAL_TORII_ENCRYPTION_KEY | The master encryption key, hex encoded | 64 hex chars (256-bit) is what the plugin generates; 32 hex chars (128-bit) is accepted. A definition in wp-config.php or the environment always takes precedence over the vault file. |
| WPAL_TORII_VAULT_KEYS | Manifest of the constants the vault manages | Written and maintained by the plugin inside the vault file. You should never need to touch it. |
| WPAL_TORII_SECRETS_NO_OBJECT_CACHE | Disables object caching of decrypted secrets | Define as true when your object cache backend persists to disk, such as Redis with AOF or RDB enabled, to keep plaintext out of the persistence file. |
Connector constants of the form TORII_<CONNECTOR>_<FIELD> are listed in each connector's documentation and on the connection screens themselves; they are managed through the vault, not typed anywhere.
Related reading: Data Handling covers what data leaves your site and where it goes, which is the natural companion question to where your secrets stay.