WordPress Web Crypto API Security: Client-Side Encryption and Database Storage

Build a WordPress plugin that encrypts form data in the browser with the Web Crypto API (AES-GCM, PBKDF2) and stores only ciphertext in the database.

WordPress Web Crypto API Security

As a developer working with WordPress sites, you’ve likely encountered security concerns when handling sensitive data. You might have seen users’ passwords and other critical information stored in plain text, making your site vulnerable to data breaches. This is especially true for small businesses or organizations that rely heavily on their website’s security.

You’ll build a secure WordPress plugin using the browser’s Web Crypto API to protect sensitive user data. By the end of this tutorial, you’ll have implemented client-side encryption for a form and stored encrypted data in your WordPress database. You’ll learn how to use JavaScript to encrypt data with the Web Crypto API and then retrieve it securely from your database, ensuring that even if an attacker obtains a copy of your database, they won’t be able to read sensitive information.

Prerequisites: Installing WordPress for Development with WP-CLI

Before diving into the exciting world of secure data encryption using Web Crypto API in a WordPress plugin, we need to set up our development environment. For this tutorial, I’ll be using WordPress as the CMS, WP-CLI to install it, and a local MySQL or MariaDB server for the database.

First, let’s install WP-CLI, the official command-line tool for WordPress:

curl -O https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar
chmod +x wp-cli.phar
sudo mv wp-cli.phar /usr/local/bin/wp

This will download the WP-CLI Phar archive and make it available globally as the wp command. Create a directory called web-crypto-api-tutorial, change into it, and confirm that WP-CLI works:

mkdir web-crypto-api-tutorial
cd web-crypto-api-tutorial
wp --info

WP-CLI will print your PHP version and configuration. You’ll also need a running MySQL or MariaDB server, either installed locally or running in a Docker container.

Next, download and install WordPress using WP-CLI by running:

wp core download
wp config create --dbname=wordpress --dbuser=root --dbpass=password --dbhost=localhost --dbprefix=wp_
wp db create
wp core install --url=http://localhost:8080 --title="Web Crypto Demo" --admin_user=admin --admin_password=password --admin_email=admin@example.com

These commands will set up a fresh WordPress installation in the web-crypto-api-tutorial directory.

Make sure the database credentials you pass to wp config create match your MySQL server. You can then run wp server --port=8080 to serve the site with PHP’s built-in web server. With this setup, we’re ready to move on to setting up a WordPress plugin structure with Composer and Laravel Mix.

Setting Up a WordPress Plugin Structure with Composer and Laravel Mix

To set up our WordPress plugin structure, we’ll start by creating a new directory for our project. From the root of your WordPress installation, create a folder named wp-crypto-api inside wp-content/plugins:

mkdir wp-content/plugins/wp-crypto-api

Navigate into the newly created directory:

cd wp-content/plugins/wp-crypto-api

Next, initialize a new Composer project:

composer init

Follow the prompts to set up our project. When asked about package types, choose “Library” for now.

Laravel Mix is an npm package, so initialize a package.json file and install Laravel Mix as a development dependency:

npm init -y
npm install laravel-mix --save-dev

Create a new file named webpack.mix.js with the following content:

const mix = require('laravel-mix');

mix
    .js('resources/js/app.js', 'public/js')
    .js('resources/js/decrypt.js', 'public/js')
    .postCss('resources/css/app.css', 'public/css');

This configuration tells Laravel Mix to compile our JavaScript and CSS files for production use. Once the source files exist, run npx mix --production to build them.

Create a new directory named resources in the root of our project:

mkdir resources

Inside the resources directory, create two new subdirectories: js and css. We’ll be adding our plugin’s JavaScript code to the app.js and decrypt.js files inside the js directory.

Now we have a basic WordPress plugin structure set up with Composer and Laravel Mix. This will allow us to easily manage dependencies and compile our assets for production use.

Implementing Web Crypto API in a WordPress Plugin

The Web Crypto API is a browser API, so all encryption and decryption happens in JavaScript; the plugin’s PHP code never sees the key or the plaintext. Create the main plugin file wp-crypto-api.php within your plugin directory. It registers the plugin with WordPress and loads the compiled script that talks to the Web Crypto API:

<?php
/**
 * Plugin Name: WP Crypto API
 * Description: Client-side encryption with the Web Crypto API.
 * Version: 1.0.0
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

require_once __DIR__ . '/includes/storage.php';
require_once __DIR__ . '/example-form.php';

add_action( 'wp_enqueue_scripts', function () {
    wp_enqueue_script(
        'wp-crypto-api',
        plugins_url( 'public/js/app.js', __FILE__ ),
        array(),
        '1.0.0',
        true
    );

    wp_localize_script( 'wp-crypto-api', 'wpCryptoApi', array(
        'restUrl' => esc_url_raw( rest_url( 'wp-crypto-api/v1/data' ) ),
        'nonce'   => wp_create_nonce( 'wp_rest' ),
    ) );
} );

wp_localize_script() exposes the REST endpoint URL and a nonce to the script as a global wpCryptoApi object, so the browser can send already-encrypted data back to WordPress. The wp_rest nonce, sent in the X-WP-Nonce header, lets WordPress recognize the logged-in user on REST requests.

In the same file, register the REST API route that receives the encrypted payload and hands it to the storage function we’ll write later:

add_action( 'rest_api_init', function () {
    register_rest_route( 'wp-crypto-api/v1', '/data', array(
        'methods'             => 'POST',
        'permission_callback' => function () {
            return is_user_logged_in();
        },
        'callback'            => function ( WP_REST_Request $request ) {
            $saved = wpse_store_encrypted_data(
                get_current_user_id(),
                sanitize_text_field( $request->get_param( 'data' ) ),
                sanitize_text_field( $request->get_param( 'iv' ) ),
                sanitize_text_field( $request->get_param( 'salt' ) )
            );

            return rest_ensure_response( array( 'saved' => false !== $saved ) );
        },
    ) );
} );

Now the plugin loads the encryption script and has an endpoint for the encrypted data. In the next section, we’ll explore how to encrypt data in a WordPress form using JavaScript and the Web Crypto API.

Encrypting Sensitive Data in a WordPress Form using JavaScript

To encrypt sensitive data submitted through your WordPress form, you’ll need to use the Web Crypto API in conjunction with JavaScript.

First, ensure that the window.crypto object is available by checking for its presence:

if (!window.crypto || !window.crypto.subtle) {
    console.error('Web Crypto API not supported');
}

If it’s supported, you can proceed to create a 256-bit AES-GCM key. Because the same key is needed later to decrypt the data, derive it from a passphrase that only the user knows using PBKDF2 and the deriveKey method, instead of generating a random key that would be lost when the page closes:

export async function deriveKey(passphrase, salt) {
    try {
        const baseKey = await window.crypto.subtle.importKey(
            'raw',
            new TextEncoder().encode(passphrase),
            'PBKDF2',
            false,
            ['deriveKey']
        );

        return await window.crypto.subtle.deriveKey(
            { name: 'PBKDF2', salt, iterations: 600000, hash: 'SHA-256' },
            baseKey,
            { name: 'AES-GCM', length: 256 },
            false,
            ['encrypt', 'decrypt']
        );
    } catch (error) {
        console.error('Error deriving key:', error);
        throw error;
    }
}

Next, you’ll need to create a function to encrypt the form data. This can be done using the encrypt method. AES-GCM needs a fresh, random 12-byte IV for every encryption with the same key, and the API works on bytes, so the text is encoded first and the result is converted to Base64 for transport:

async function encryptData(plaintext, key) {
    try {
        const iv = window.crypto.getRandomValues(new Uint8Array(12));
        const encrypted = await window.crypto.subtle.encrypt({
            name: 'AES-GCM',
            iv,
        }, key, new TextEncoder().encode(plaintext));
        return { ciphertext: new Uint8Array(encrypted), iv };
    } catch (error) {
        console.error('Error encrypting data:', error);
        throw error;
    }
}

function toBase64(bytes) {
    return btoa(String.fromCharCode(...bytes));
}

In your form submission handler, you can call the encryptData function to encrypt the sensitive data before sending it to the server. All of the JavaScript in this section goes in resources/js/app.js. The handler expects a form with the ID myForm containing username, password and passphrase fields, for example:

<form id="myForm">
    <input type="text" name="username" placeholder="Username">
    <input type="password" name="password" placeholder="Password">
    <input type="password" name="passphrase" placeholder="Encryption passphrase">
    <button type="submit">Save</button>
</form>

Then add the submit handler:

const form = document.getElementById('myForm');
if (form) {
    form.addEventListener('submit', async (e) => {
        e.preventDefault();
        const formData = new FormData(e.target);
        const salt = window.crypto.getRandomValues(new Uint8Array(16));
        const key = await deriveKey(formData.get('passphrase'), salt);
        const { ciphertext, iv } = await encryptData(JSON.stringify({
            username: formData.get('username'),
            password: formData.get('password'),
        }), key);

        // Send only the ciphertext, IV and salt to the WordPress REST API; the passphrase never leaves the browser
        await fetch(wpCryptoApi.restUrl, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'X-WP-Nonce': wpCryptoApi.nonce,
            },
            body: JSON.stringify({
                data: toBase64(ciphertext),
                iv: toBase64(iv),
                salt: toBase64(salt),
            }),
        });
    });
}

By following these steps, you can securely encrypt sensitive form data using the Web Crypto API and JavaScript. This approach ensures that even if an attacker gains access to your database or server logs, they won’t be able to extract sensitive information from the stored data.

Storing Encrypted Data in the WordPress Database

Now that we have a working encryption mechanism using the Web Crypto API, let’s create a function in our plugin to store the encrypted data in the WordPress database.

First, create an includes/storage.php file in your plugin (the main plugin file already requires it) with this code:

<?php
// includes/storage.php

function wpse_store_encrypted_data( $user_id, $data, $iv, $salt ) {
    global $wpdb;

    // The data arrives already encrypted by the browser, together with its random IV and PBKDF2 salt
    return $wpdb->replace(
        $wpdb->prefix . 'wpse_encrypted_data',
        array(
            'user_id' => $user_id,
            'data'    => $data,
            'iv'      => $iv,
            'salt'    => $salt,
        ),
        array( '%d', '%s', '%s', '%s' )
    );
}

function wpse_get_encrypted_data( $user_id ) {
    global $wpdb;

    return $wpdb->get_row(
        $wpdb->prepare(
            "SELECT data, iv, salt FROM {$wpdb->prefix}wpse_encrypted_data WHERE user_id = %d",
            $user_id
        ),
        ARRAY_A
    );
}

Next, create a new database table in your WordPress database to store the encrypted data. Add an activation hook to wp-crypto-api.php so WordPress creates the table with dbDelta() when the plugin is activated:

register_activation_hook( __FILE__, function () {
    global $wpdb;
    require_once ABSPATH . 'wp-admin/includes/upgrade.php';

    $table   = $wpdb->prefix . 'wpse_encrypted_data';
    $charset = $wpdb->get_charset_collate();

    dbDelta( "CREATE TABLE $table (
  user_id bigint(20) unsigned NOT NULL,
  data text NOT NULL,
  iv varchar(32) NOT NULL,
  salt varchar(32) NOT NULL,
  PRIMARY KEY  (user_id)
) $charset;" );
} );

Now you can use wpse_store_encrypted_data() whenever you need to save sensitive data, like form submissions or user input. Remember that the encrypted data will be stored in a custom table, allowing for more secure storage of sensitive information.

Decrypting Stored Data for Display Purposes

Now that we have stored encrypted data in our WordPress database using the Web Crypto API on the client side, it’s time to decrypt and display that information on the frontend of our website.

In this example, let’s assume we’re building a simple password manager plugin. When users fill out their login credentials in a form and submit them for storage, those credentials are encrypted with a key that the Web Crypto API derives from the user’s passphrase. However, when it comes time to display these credentials (e.g., on the user dashboard), we need to derive the same key again and decrypt the stored data using the same algorithm.

To achieve this, we’ll create a new JavaScript file in our plugin directory, resources/js/decrypt.js (already listed in webpack.mix.js). This script will use the Web Crypto API’s decrypt() function to decode the encrypted data stored in the WordPress database. On the dashboard page, output the row returned by wpse_get_encrypted_data() into a data-credentials attribute (for example with esc_attr( wp_json_encode( $row ) )) on an element with the ID user-credentials, and enqueue the compiled public/js/decrypt.js the same way as app.js. We’ll then display the decrypted credentials on the user dashboard.

// resources/js/decrypt.js
import { deriveKey } from './app';

const fromBase64 = (value) => Uint8Array.from(atob(value), (c) => c.charCodeAt(0));

const decryptData = async ({ data, iv, salt }, passphrase) => {
    try {
        // Re-derive the same AES-GCM key from the passphrase and the stored salt
        const key = await deriveKey(passphrase, fromBase64(salt));
        const decryptedData = await window.crypto.subtle.decrypt(
            { name: 'AES-GCM', iv: fromBase64(iv) },
            key,
            fromBase64(data)
        );
        return new TextDecoder('utf-8').decode(decryptedData);
    } catch (e) {
        console.error(e);
        throw e;
    }
};

const userCredentials = document.getElementById('user-credentials');
if (userCredentials !== null) {
    const encryptedCredentials = userCredentials.dataset.credentials;
    if (encryptedCredentials) {
        const passphrase = window.prompt('Enter your passphrase to view your credentials');
        decryptData(JSON.parse(encryptedCredentials), passphrase).then((decryptedCredentials) => {
            const credentials = JSON.parse(decryptedCredentials);
            userCredentials.textContent = `Username: ${credentials.username}\nPassword: ${credentials.password}`;
        });
    }
}

This is a basic example of how to decrypt stored data for display purposes in our WordPress plugin. By using the Web Crypto API’s decryption functions, we ensure that sensitive information remains secure throughout the process: the server only ever stores ciphertext, and a wrong passphrase makes AES-GCM decryption fail instead of returning garbage. Keep in mind that this protects the stored data, not the page itself: anyone who can inject JavaScript into your site (through an XSS flaw or a compromised plugin) could capture the passphrase as it is typed.

With this final step complete, we have successfully implemented encryption and decryption using the Web Crypto API within our WordPress plugin.

Testing Client-Side Encryption with a Sample Use Case

Now that you have implemented client-side encryption using Web Crypto API in your WordPress plugin, it’s time to test its functionality with a sample use case.

Let’s consider an example where we need to encrypt sensitive user data such as passwords or credit card numbers. We’ll create a simple form with input fields for username and password, and then use JavaScript to encrypt the submitted data before sending it to our WordPress server.

Create a new PHP file example-form.php in your plugin directory, and add the following code:

<?php
// example-form.php

function register_example_form_shortcode() {
    add_shortcode('example_form', function () {
        ob_start();
        include __DIR__ . '/templates/example-form.php';
        return ob_get_clean();
    });
}

add_action('init', 'register_example_form_shortcode');

This registers an [example_form] shortcode (the main plugin file already requires example-form.php). Create a new PHP template templates/example-form.php in your plugin directory, and add the following code:

<!-- templates/example-form.php -->

<form id="example-form">
    <input type="text" name="username" placeholder="Username">
    <input type="password" name="password" placeholder="Password">
    <button type="submit">Submit</button>
</form>

<script>
// client-side JavaScript code to encrypt submitted data
const form = document.getElementById('example-form');
form.addEventListener('submit', async (e) => {
    e.preventDefault();
    const username = form.username.value;
    const password = form.password.value;
    
    // Create a new key pair for encryption
    const keyPair = await window.crypto.subtle.generateKey({
        name: 'RSA-OAEP',
        modulusLength: 2048,
        publicExponent: new Uint8Array([1, 0, 1]),
        hash: 'SHA-256'
    }, true, ['encrypt', 'decrypt']);
    
    // Encrypt data
    const encryptedUsername = await window.crypto.subtle.encrypt(
        { name: 'RSA-OAEP' },
        keyPair.publicKey,
        new TextEncoder().encode(username)
    );

    const encryptedPassword = await window.crypto.subtle.encrypt(
        { name: 'RSA-OAEP' },
        keyPair.publicKey,
        new TextEncoder().encode(password)
    );

    // Decrypt with the private key to verify the round trip
    const decryptedUsername = new TextDecoder().decode(
        await window.crypto.subtle.decrypt({ name: 'RSA-OAEP' }, keyPair.privateKey, encryptedUsername)
    );
    const decryptedPassword = new TextDecoder().decode(
        await window.crypto.subtle.decrypt({ name: 'RSA-OAEP' }, keyPair.privateKey, encryptedPassword)
    );

    console.log('Ciphertext sizes (bytes):', encryptedUsername.byteLength, encryptedPassword.byteLength);
    console.log('Round trip OK:', decryptedUsername === username && decryptedPassword === password);
});
</script>

This code will create a simple form with input fields for username and password. When the user submits the form, client-side JavaScript code will encrypt the submitted data using the Web Crypto API (here with an RSA-OAEP key pair) and decrypt it again with the private key, so you can confirm in the browser console that encryption works before wiring the form to the AES-GCM flow that sends ciphertext to our WordPress server.

Make sure your WordPress server-side code only accepts and stores the encrypted data, as the REST route above does, and that decryption happens in the browser when the data is needed for display purposes.

Frequently Asked Questions

How do I install WordPress for development?

To set up your development environment, install WP-CLI, create a web-crypto-api-tutorial directory and change into it. Then run wp core download, wp config create, wp db create and wp core install as described in the article, and serve the site locally with wp server.

What if I encounter an error installing WordPress?

If you encounter issues during installation, ensure that your MySQL or MariaDB server is running and that the credentials in wp-config.php are correct; wp db check is a quick way to test the connection. Check the server logs for any errors related to MySQL or PHP. You can also refer to the official documentation for troubleshooting guides specific to WordPress and WP-CLI.

Is using Web Crypto API more secure than traditional encryption methods?

It is generally safer than hand-written or third-party JavaScript crypto code, because the Web Crypto API provides a standardized set of well-vetted algorithms implemented natively by the browser. It is also faster and easier to maintain. However, the overall security still depends on how you manage keys, IVs and passphrases.

What are some common pitfalls when working with client-side encryption?

Never send the passphrase or the derived key to the server or store it next to the ciphertext, never reuse an IV with the same AES-GCM key, and remember that a cross-site scripting (XSS) flaw lets an attacker read data in the page before it is encrypted. Additionally, ensure that you properly handle key management and rotation to prevent potential vulnerabilities.

Can I use the Web Crypto API outside WordPress?

Yes. While this tutorial focuses on using WordPress with the Web Crypto API, the API is a browser standard that runs entirely on the client, so it works with any backend, including Laravel, Symfony or plain PHP. The backend only needs to store and return the ciphertext, IV and salt. However, you may need to adapt the implementation details depending on your specific framework’s requirements.

Comments

comments