WP Tuts Hub

WordPress Tutorials, Themes and Plugins Hub
Advertisement

Browse

  • Home
  • Categories
    • WordPress Troubleshooting
    • WordPress Tips and Tricks
    • SSH (Secure Shell)
    • WordPress Insights
    • WordPress Common Facts
    • Securing WordPress
  • Series Tutorials
    • Advanced WordPress Topic
  • Privacy Policy
  • Terms of Use
  • Contact Me
  • About WP Tuts Hub
Home/Advanced WordPress Topic/Encrypt WordPress AI Plugin API Keys at Rest with Sodium: Capability-Gated Decryption and Key Rotation
Advanced WordPress Topic
November 7, 2026

Encrypt WordPress AI Plugin API Keys at Rest with Sodium: Capability-Gated Decryption and Key Rotation

Encrypt WordPress AI Plugin API Keys at Rest with Sodium: Capability-Gated Decryption and Key Rotation

What you are building

An AI plugin that talks to OpenAI, Anthropic or Gemini needs a long-lived secret. If you drop that secret into wp_options as plain text, anyone with a read-only database copy — an errant backup, a stolen mysqldump, a staging clone pushed to a shared host — owns your provider account. This tutorial stores provider keys as sodium_crypto_secretbox ciphertext, unwraps them only inside server-side calls guarded by current_user_can( 'manage_options' ), and adds a WP-CLI rotation command that re-wraps the whole keyring without ever printing plaintext.

The design uses two key tiers. A key encryption key (KEK) is derived from AUTH_KEY and SECURE_AUTH_KEY and is never written to the database. A random data encryption key (DEK) is wrapped by the KEK and stored. Provider secrets are sealed by the DEK. That extra hop is what makes rotation possible: swap the DEK and re-seal the keyring in one pass, without touching wp-config.php.

Prerequisites

  • PHP 7.2 or newer, built with libsodium. Confirm with php -m | grep sodium. Modern builds ship it as standard.
  • WordPress 6.2+, with unique, non-placeholder salts in wp-config.php. If your salts still say put your unique phrase here, generate fresh ones before you start.
  • Write access to a plugin directory (this code is written as a plugin, not a theme).
  • WP-CLI available on the server for the rotation step.
  • Familiarity with register_setting() and the sanitising API.

A blunt threat-model note before you begin: this protects secrets from leaking through stored data. It does not protect you from an attacker who can already execute PHP on the site — at that point they can call your decrypt function. The goal is to shrink the blast radius of every database-level leak, not to build a KMS.

Step 1 Derive the KEK from your salts

sodium_crypto_secretbox needs a 32-byte key. Salts are already random and already outside the database, which is exactly the property we want. Hash them together with a domain-separation string using sodium_crypto_generichash(), which is BLAKE2b — no md5, no sha1.

function wpt_ai_kek() {
    if ( ! defined( 'AUTH_KEY' ) || ! defined( 'SECURE_AUTH_KEY' ) ) {
        throw new RuntimeException( 'AUTH_KEY and SECURE_AUTH_KEY must be defined in wp-config.php.' );
    }

    return sodium_crypto_generichash(
        'wpt-ai-kek-v1|' . AUTH_KEY . '|' . SECURE_AUTH_KEY,
        '',
        SODIUM_CRYPTO_SECRETBOX_KEYBYTES
    );
}

function wpt_ai_seal( $plaintext, $key ) {
    $nonce  = random_bytes( SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
    $cipher = sodium_crypto_secretbox( $plaintext, $nonce, $key );

    return base64_encode( $nonce . $cipher );
}

function wpt_ai_open( $payload, $key ) {
    $raw = base64_decode( (string) $payload, true );
    if ( false === $raw ) {
        return null;
    }

    $min = SODIUM_CRYPTO_SECRETBOX_NONCEBYTES + SODIUM_CRYPTO_SECRETBOX_MACBYTES;
    if ( strlen( $raw ) <= $min ) {
        return null;
    }

    $nonce  = substr( $raw, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
    $cipher = substr( $raw, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
    $plain  = sodium_crypto_secretbox_open( $cipher, $nonce, $key );

    return ( false === $plain ) ? null : $plain;
}

Why this shape: the nonce is prepended to the ciphertext rather than stored separately, so a single option value is self-describing. The secretbox MAC is included by default, which means wpt_ai_open() returns false on any tampering — including a truncated base64 string from a careless copy-paste. Always check for null, never assume success.

One deliberate consequence: rotating your salts destroys the keyring. That is a feature, not a bug — if wp-config.php leaks, you regenerate salts and every stored provider key becomes unreadable noise. Document it so nobody rotates salts casually on a Tuesday.

Step 2 Wrap a data key so rotation stays possible

If provider secrets were sealed directly by the KEK, rotating them would mean editing wp-config.php and re-running everything by hand. Instead, generate a random DEK once, wrap it with the KEK, and store only the wrapped version.

function wpt_ai_dek_ensure() {
    $wrapped = get_option( 'wpt_ai_dek' );
    $kek     = wpt_ai_kek();

    if ( is_string( $wrapped ) && '' !== $wrapped ) {
        $dek = wpt_ai_open( $wrapped, $kek );
        // Older installs fail here if wp-config has been corrupted.
        if ( is_string( $dek ) && strlen( $dek ) === SODIUM_CRYPTO_SECRETBOX_KEYBYTES ) {
            return $dek;
        }
    }

    $dek = random_bytes( SODIUM_CRYPTO_SECRETBOX_KEYBYTES );
    update_option( 'wpt_ai_dek', wpt_ai_seal( $dek, $kek ), false );

    return $dek;
}

function wpt_ai_store_key( $provider, $plaintext ) {
    $dek = wpt_ai_dek_ensure();

    $keys               = (array) get_option( 'wpt_ai_provider_keys', array() );
    $keys[ $provider ]  = wpt_ai_seal( $plaintext, $dek );

    $saved = update_option( 'wpt_ai_provider_keys', $keys, false );
    sodium_memzero( $dek );

    return $saved;
}

The third argument false to update_option() sets autoload = no. This matters: an autoloaded option is pulled into memory on every page load, so an attacker with an object-cache dump gets your ciphertexts for free. Ciphertext on every request is still a lot of unnecessary exposure surface.

Step 3 Write only ciphertext from the settings page

The cardinal rule: plaintext enters PHP from $_POST, gets encrypted in the sanitise callback, and never returns to the browser. The form field is always empty — it is a write-only slot. The page shows Saved or Not set, never the key or a hint of it.

const WPT_AI_PROVIDERS = array( 'openai', 'anthropic', 'gemini' );

add_action( 'admin_init', function () {
    register_setting( 'wpt_ai_keys', 'wpt_ai_provider_keys', array(
        'type'              => 'array',
        'sanitize_callback' => 'wpt_ai_sanitize_keys',
        'default'           => array(),
        'show_in_rest'      => false, // Never expose wrapped keys over REST.
    ) );
} );

function wpt_ai_sanitize_keys( $incoming ) {
    $stored = (array) get_option( 'wpt_ai_provider_keys', array() );
    $clean  = array();
    $dek    = wpt_ai_dek_ensure();

    foreach ( WPT_AI_PROVIDERS as $provider ) {
        $raw = isset( $_POST['wpt_ai_new_keys'][ $provider ] )
            ? trim( (string) wp_unslash( $_POST['wpt_ai_new_keys'][ $provider ] ) )
            : '';

        if ( ! empty( $_POST['wpt_ai_clear'][ $provider ] ) ) {
            continue; // Drop the entry entirely.
        }

        if ( '' !== $raw ) {
            $clean[ $provider ] = wpt_ai_seal( $raw, $dek );
        } elseif ( isset( $stored[ $provider ] ) ) {
            $clean[ $provider ] = $stored[ $provider ];
        }
    }

    sodium_memzero( $dek );

    return $clean;
}

The elseif branch is the one people forget. Without it, saving the page with empty fields wipes every previously stored key, and you get a support ticket that reads “my keys vanish on save”. Preserve what is already there unless the administrator explicitly typed a replacement or ticked the clear box.

Render the form with settings_fields( 'wpt_ai_keys' ) so the nonce and option-group match register_setting(), use type="password" with autocomplete="new-password" to stop browsers prefilling the field, and guard the render callback itself with a capability check plus wp_die().

Step 4 Decrypt only behind a capability gate

Decryption belongs inside the function that makes the outbound HTTP request, not in a general-purpose getter sprinkled around the codebase. Put the capability check first, before the key is even fetched.

function wpt_ai_get_key( $provider ) {
    $keys = (array) get_option( 'wpt_ai_provider_keys', array() );
    if ( empty( $keys[ $provider ] ) ) {
        return null;
    }

    $dek   = wpt_ai_dek_ensure();
    $plain = wpt_ai_open( $keys[ $provider ], $dek );
    sodium_memzero( $dek );

    return $plain;
}

function wpt_ai_request_completion( $prompt ) {
    if ( ! current_user_can( 'manage_options' ) ) {
        return new WP_Error( 'wpt_forbidden', __( 'Insufficient capability.', 'wpt' ), array( 'status' => 403 ) );
    }

    $key = wpt_ai_get_key( 'openai' );
    if ( null === $key ) {
        return new WP_Error( 'wpt_no_key', __( 'No OpenAI key stored.', 'wpt' ) );
    }

    $response = wp_remote_post(
        'https://api.openai.com/v1/chat/completions',
        array(
            'timeout' => 30,
            'headers' => array(
                'Authorization' => 'Bearer ' . $key,
                'Content-Type'  => 'application/json',
            ),
            'body'    => wp_json_encode( array(
                'model'    => 'gpt-4o-mini',
                'messages' => array( array( 'role' => 'user', 'content' => $prompt ) ),
            ) ),
        )
    );

    sodium_memzero( $key ); // Overwrite the secret in memory as soon as it is used.

    if ( is_wp_error( $response ) ) {
        return $response;
    }

    $code = (int) wp_remote_retrieve_response_code( $response );
    if ( 200 !== $code ) {
        return new WP_Error( 'wpt_upstream', sprintf( 'Provider returned HTTP %d.', $code ) );
    }

    $body = json_decode( wp_remote_retrieve_body( $response ), true );

    return (string) ( $body['choices'][0]['message']['content'] ?? '' );
}

If you expose this through a REST route, use permission_callback that mirrors the same check — do not rely on the internal guard alone, and never return the key in the response payload. The route returns completions; the secret stays server-side. Pair this with wp_remote_post() and WordPress’s own HTTP transport rather than raw cURL, so proxies and timeouts behave predictably.

Ciphertext in the database is worthless to an attacker who cannot run code on the site. The moment your plugin echoes a decrypted key into an admin screen, an AJAX response or a debug log, that property evaporates. Keep decryption inside the request path only.

Step 5 Rotate the keyring with WP-CLI

Rotation generates a fresh DEK, re-seals every provider secret under it, then swaps the wrapped DEK. Plaintext exists only as a local variable between two function calls.

if ( defined( 'WP_CLI' ) && WP_CLI ) {
    WP_CLI::add_command( 'wpt-ai rotate-key', function () {
        $keys = (array) get_option( 'wpt_ai_provider_keys', array() );
        if ( empty( $keys ) ) {
            WP_CLI::error( 'No stored provider keys to rotate.' );
        }

        $old = wpt_ai_dek_ensure();
        $new = random_bytes( SODIUM_CRYPTO_SECRETBOX_KEYBYTES );
        $rotated = array();

        foreach ( $keys as $provider => $payload ) {
            $plain = wpt_ai_open( $payload, $old );
            if ( null === $plain ) {
                WP_CLI::warning( sprintf( 'Could not decrypt %s — leaving it untouched.', $provider ) );
                $rotated[ $provider ] = $payload;
                continue;
            }

            $rotated[ $provider ] = wpt_ai_seal( $plain, $new );
            sodium_memzero( $plain );
        }

        // Write re-sealed secrets first, then the new wrapped DEK.
        update_option( 'wpt_ai_provider_keys', $rotated, false );

        $kek = wpt_ai_kek();
        update_option( 'wpt_ai_dek', wpt_ai_seal( $new, $kek ), false );

        sodium_memzero( $new );
        sodium_memzero( $old );
        sodium_memzero( $kek );

        WP_CLI::success( sprintf( 'Rotated %d provider key(s).', count( $rotated ) ) );
    } );
}

Run it with a database backup already in hand, because the two update_option() calls are not a transaction:

wp db export backup-before-rotation.sql
wp wpt-ai rotate-key
wp option get wpt_ai_provider_keys --format=json
wp db query "SELECT autoload FROM wp_options WHERE option_name='wpt_ai_dek';"

Expect no from that last query. If it says yes, your earlier update_option() call is missing its third argument somewhere.

Troubleshooting

  • “Call to undefined function sodium_crypto_secretbox_open()” — PHP lacks libsodium. Run php -m | grep sodium; on anything older than 7.2 install the libsodium extension, or upgrade PHP.
  • Every key returns null after a migration — the salts differ between environments. Copy AUTH_KEY and SECURE_AUTH_KEY byte-for-byte, and watch for trailing whitespace or smart quotes introduced by a paste.
  • Keys disappear on save — your sanitise callback returned only the newly submitted providers. Restore the elseif ( isset( $stored[ $provider ] ) ) branch.
  • “Options page not updated” notice — the group string in settings_fields() does not match the second argument to register_setting().
  • Rotation left some keys unreadable — the script warned about a provider it could not decrypt and copied the old ciphertext forward. Restore the export and investigate before rotating again.
  • Everything breaks after someone regenerated salts — expected. Re-enter each provider key through the settings page; there is no recovery path, which is precisely why the KEK lives in wp-config.php.

Next steps

  • Expose non-secret metadata (provider label, last rotated timestamp, model name) through block bindings so editors can see status without any decrypt path touching a render callback.
  • Wrap the API call in a custom REST route whose permission_callback reuses the same manage_options check, and rate-limit it to blunt abuse of your provider quota.
  • Add a health check that calls wpt_ai_open() on each stored payload and reports failures in wp admin — catching salt drift before a user does.
  • Audit for leaks: grep your plugin for the option names and confirm no code passes a decrypted key to error_log(), a transient, or a screen that a lesser role can see.

Share this:

  • Share on X (Opens in new window) X
  • Share on Facebook (Opens in new window) Facebook
  • Share on LinkedIn (Opens in new window) LinkedIn
  • Share on WhatsApp (Opens in new window) WhatsApp
  • Share on Reddit (Opens in new window) Reddit
  • Email a link to a friend (Opens in new window) Email

Related

Previous Article AI Translation Workflow for WordPress Posts: Glossary-Aware LLM Drafts with Human Review and Locale Linking

About the author

Md. Zubaer Ahammed
Md. Zubaer Ahammed

Md. Zubaer Ahammed is a senior full-stack developer building learning platforms and modern web systems with WordPress, Laravel, Python/Django, Vue, and React. More at https://zubaer.com/.

Related Posts

  • AI Translation Workflow for WordPress Posts: Glossary-Aware LLM Drafts with Human Review and Locale Linking AI Translation Workflow for WordPress Posts: Glossary-Aware LLM Drafts with Human Review and Locale Linking
  • AI Internal Linking Suggestions in Gutenberg: Embedding Similar Posts with Human-Approved Insert AI Internal Linking Suggestions in Gutenberg: Embedding Similar Posts with Human-Approved Insert

Leave a Reply

Cancel reply

WP Tuts Hub

WordPress tutorials, themes, and plugins — written for builders.

  • Portfolio
  • Calorie Calculator Pro
  • Contact
  • About
  • Privacy
  • Terms

© 2026 WP Tuts Hub · Md. Zubaer Ahammed