When building web applications with sensitive user data, you’ve likely run into the challenge of securely handling encryption. As your app grows and users entrust it with their personal information, protecting that data becomes a top priority. However, implementing robust encryption can be daunting, especially when dealing with dynamic input fields and encrypting/decrypting data on the fly.
You’ll build a React application that securely handles user input using the Web Crypto API, ensuring sensitive data remains encrypted even after it’s stored or transmitted. By the end of this guide, you’ll have implemented client-side encryption for form inputs and learned how to decrypt and render that data in your app, all while handling errors and edge cases effectively.
Setting Up a New React Project with Client-Side Encryption
To start implementing client-side encryption using the Web Crypto API in our React app, we first need to set up a new project. Let’s use Vite with its React + TypeScript template for this.
npm create vite@latest encrypted-react-app -- --template react-ts
cd encrypted-react-app
npm install
Next, install the necessary dependencies: axios for sending requests to the backend. TypeScript is already included for type checking by the react-ts template.
npm install axios
We’ll also install the standalone react-devtools package for debugging purposes (alternatively, you can use the React Developer Tools browser extension).
npm install --save-dev react-devtools
The template generates a tsconfig.app.json file for your application code. Make sure its compiler options include the following configuration:
{
"compilerOptions": {
// Other options...
"moduleResolution": "bundler",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"jsx": "react-jsx",
// Other options...
}
}
Replace the contents of the main.tsx file in the src directory:
import React from 'react';
import ReactDOM from 'react-dom/client';
function App() {
return (
<div>
<h1>Encrypted React App</h1>
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
This is a basic setup for our React app. In the next section, we’ll delve into understanding the Web Crypto API and its purpose in our React application.
Understanding the Web Crypto API and its Purpose in React
The Web Crypto API is a JavaScript API that provides functions for performing basic cryptographic operations in web applications. It’s designed to be used in client-side scripts, allowing developers to perform encryption, decryption, digital signing, and other cryptographic tasks directly within the browser.
To get started with the Web Crypto API, you don’t need to import anything into your React application: the browser exposes it globally as window.crypto, and its cryptographic operations (key generation, encryption, decryption, signing) live on crypto.subtle. Here’s an example of how to use the Web Crypto API:
import { useState } from 'react';
const App = () => {
const [key, setKey] = useState<CryptoKey | null>(null);
const generateKey = async () => {
try {
const key = await window.crypto.subtle.generateKey(
{
name: 'AES-GCM',
length: 256,
},
true,
['encrypt', 'decrypt']
);
setKey(key);
} catch (error) {
console.error('Error generating key:', error);
}
};
return (
<div>
<button onClick={generateKey}>Generate Key</button>
<p>Generated Key: {key ? `${key.algorithm.name} (extractable: ${key.extractable})` : 'none'}</p>
</div>
);
};
This code snippet generates a new AES-GCM encryption key using the window.crypto.subtle.generateKey method. The generated CryptoKey is then stored in the component’s state and its algorithm details are displayed on screen.
The Web Crypto API provides various methods for performing cryptographic operations, including generating keys, encrypting data, decrypting data, and more. In the next section, we’ll dive deeper into using these methods to implement encryption and decryption functionality within your React application.
Generating Keys and Encrypting Data with the Web Crypto API
To work with encryption in our React app, we’ll first need to generate keys using the Web Crypto API. This process involves creating a single secret key that is used for both encryption and decryption, since AES-GCM is a symmetric algorithm.
Let’s assume we’re working within a React functional component. We can use the window.crypto object provided by the browser to access the Web Crypto API.
import { useState, useEffect } from 'react';
function App() {
const [key, setKey] = useState<CryptoKey | null>(null);
const [encryptedData, setEncryptedData] = useState<string | null>(null);
useEffect(() => {
generateKey();
}, []);
async function generateKey() {
try {
const secretKey = await window.crypto.subtle.generateKey(
{
name: 'AES-GCM',
length: 256,
},
true,
['encrypt', 'decrypt']
);
setKey(secretKey);
} catch (error) {
console.error('Error generating key:', error);
}
}
async function encryptData(data: string) {
if (!key) return;
try {
// AES-GCM needs a unique, random 12-byte IV for every encryption
const iv = window.crypto.getRandomValues(new Uint8Array(12));
const encrypted = await window.crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv,
},
key,
new TextEncoder().encode(data)
);
// The result is an ArrayBuffer, so convert it to Base64 for display
setEncryptedData(btoa(String.fromCharCode(...new Uint8Array(encrypted))));
} catch (error) {
console.error('Error encrypting data:', error);
}
}
return (
<div>
<button onClick={() => encryptData('Hello, World!')}>Encrypt Data</button>
{encryptedData && <p>{encryptedData}</p>}
</div>
);
}
In the code above, we use window.crypto.subtle.generateKey to generate a 256-bit AES-GCM secret key. We then store this key in our component’s state. The encryptData function uses this key, together with a fresh random initialization vector (IV), to encrypt some sample data using the AES-GCM algorithm.
Note that you should never share your encryption keys or encrypted data directly with others, as they can be used for malicious purposes. This is just a basic example of how to work with the Web Crypto API in React.
Implementing Encryption on User Input Fields in Your React App
Now that you’ve generated keys and can encrypt data using the Web Crypto API, it’s time to integrate encryption into your user input fields. To do this, we’ll create a custom TextInput component that uses an encryptInput function. First, move the key handling and encryption logic from the previous section into a shared utility module, so every component uses the same key:
// src/utils/encryption.ts
let keyPromise: Promise<CryptoKey> | null = null;
export const toBase64 = (bytes: Uint8Array) => btoa(String.fromCharCode(...bytes));
export const fromBase64 = (base64: string) => Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
export function getKey(): Promise<CryptoKey> {
if (!keyPromise) {
keyPromise = window.crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt']
);
}
return keyPromise;
}
export async function encryptInput(plaintext: string): Promise<string> {
const key = await getKey();
const iv = window.crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await window.crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
new TextEncoder().encode(plaintext)
);
// Prepend the IV so the payload carries everything needed for decryption
const payload = new Uint8Array(iv.length + ciphertext.byteLength);
payload.set(iv);
payload.set(new Uint8Array(ciphertext), iv.length);
return toBase64(payload);
}
Now create the TextInput component itself:
// src/components/TextInput.tsx
import { useState, type ChangeEvent } from 'react';
import { encryptInput } from '../utils/encryption';
type TextInputProps = {
label: string;
name: string;
onEncryptedChange?: (encryptedValue: string) => void;
};
const TextInput = ({ label, name, onEncryptedChange }: TextInputProps) => {
const [value, setValue] = useState('');
const handleChange = async (event: ChangeEvent<HTMLInputElement>) => {
const plaintext = event.target.value;
setValue(plaintext);
const encryptedValue = await encryptInput(plaintext);
onEncryptedChange?.(encryptedValue);
};
return (
<div>
<label>{label}</label>
<input type="text" name={name} value={value} onChange={handleChange} />
</div>
);
};
export default TextInput;
In this example, we’re using the encryptInput function to encrypt the user’s input on every keystroke. The field keeps showing what the user typed, while the encrypted value is passed to the parent through the onEncryptedChange callback. This is a simple approach for demonstration purposes, but you may want to optimize this logic depending on your specific use case.
To make things more convenient, let’s create an EncryptableTextInput component that wraps our custom TextInput component:
// src/components/EncryptableTextInput.tsx
import type { ComponentProps } from 'react';
import TextInput from './TextInput';
const EncryptableTextInput = (props: ComponentProps<typeof TextInput>) => {
return (
<TextInput {...props} />
);
};
export default EncryptableTextInput;
With this component in place, you can now use EncryptableTextInput throughout your app to ensure that sensitive user input is encrypted.
Decrypting Encrypted Data on the Client Side for Rendering
Now that we’ve encrypted data using the Web Crypto API in our React application, it’s time to decrypt it and render it to the user. This process mirrors encryption: reuse the same key that encrypted the data, read back the IV that was stored with it, and call crypto.subtle.decrypt(). Add a decryptData function to the same utility module:
// src/utils/encryption.ts (continued)
export async function decryptData(encryptedData: string): Promise<string> {
// Use the same key that was used for encryption
const key = await getKey();
// Split the payload back into the 12-byte IV and the ciphertext
const payload = fromBase64(encryptedData);
const iv = payload.slice(0, 12);
const ciphertext = payload.slice(12);
// Decrypt and turn the bytes back into a string
const decrypted = await window.crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext);
return new TextDecoder().decode(decrypted);
}
When decrypting data, we need to ensure that the correct key is used. If a wrong or invalid key is provided, or the data has been tampered with, crypto.subtle.decrypt() rejects with an OperationError instead of returning the data.
Keep in mind that getKey() creates a new key every time the page loads, so data encrypted in an earlier session can no longer be decrypted after a reload. If encrypted data needs to survive across sessions, persist the key (for example, store a non-extractable CryptoKey in IndexedDB) or derive it from a user passphrase with crypto.subtle.deriveKey() and PBKDF2.
To use this decryptData function in your React component, you can call it whenever you receive the encrypted data from the server. Make sure to store the decrypted data securely on the client-side.
import { useState, useEffect } from 'react';
import { decryptData } from '../utils/encryption';
function MyComponent({ encryptedData }: { encryptedData: string | null }) {
const [decryptedData, setDecryptedData] = useState<string | null>(null);
useEffect(() => {
if (encryptedData) {
decryptData(encryptedData)
.then((data) => {
setDecryptedData(data);
})
.catch((error) => console.error('Error decrypting data:', error));
}
}, [encryptedData]);
return (
<div>
{decryptedData && <p>Decrypted Data: {decryptedData}</p>}
</div>
);
}
In this example, decryptData is called whenever encryptedData changes. The decrypted data is then stored in the component’s state and rendered to the user.
Integrating with a Backend Service to Handle Encrypted Data
Now that you’re encrypting data on the client-side using the Web Crypto API in your React app, it’s time to integrate this functionality with your backend service. The goal is to handle encrypted data when it’s sent from the client to the server.
To achieve this, we’ll need to modify our API endpoints to accept and process encrypted data. We’ll use a simple example where we’re sending an encrypted user message to our backend.
There’s one catch: the AES key generated in the browser never leaves it, so the server can’t use it to decrypt anything. For data the backend must read, a common pattern is to encrypt it with the server’s RSA public key using the RSA-OAEP algorithm. The browser can encrypt with the public key, but only the server, which holds the private key, can decrypt. Generate the key pair with OpenSSL:
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out storage/app/private/message_private.pem
openssl rsa -in storage/app/private/message_private.pem -pubout -out public/message_public.pem
The private key stays in storage, while the public key is served from public so the browser can fetch it. Here’s an updated endpoint in our Laravel controller:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class MessageController extends Controller
{
public function store(Request $request)
{
$validated = $request->validate(['encryptedMessage' => 'required|string']);
// Decrypt the incoming data on the server-side
$data = $this->decryptData($validated['encryptedMessage']);
// Now you can process the decrypted message as usual,
// but avoid writing sensitive plaintext to your logs
Log::info('Received encrypted message', ['length' => strlen($data)]);
return response()->json(['message' => 'Message received successfully.']);
}
// Helper function to decrypt data (using a private key stored securely on the server)
private function decryptData(string $encryptedData): string
{
$privateKey = openssl_pkey_get_private(
file_get_contents(storage_path('app/private/message_private.pem'))
);
// OPENSSL_PKCS1_OAEP_PADDING matches RSA-OAEP with SHA-1 in the browser
$decrypted = '';
if (! openssl_private_decrypt(base64_decode($encryptedData), $decrypted, $privateKey, OPENSSL_PKCS1_OAEP_PADDING)) {
abort(422, 'Unable to decrypt message.');
}
return $decrypted;
}
}
On the client-side in React, import the public key and encrypt the message with it. With a 2048-bit key and SHA-1, RSA-OAEP can encrypt messages of up to 214 bytes:
import { useState } from 'react';
import axios from 'axios';
import { toBase64, fromBase64 } from '../utils/encryption';
async function getServerPublicKey(): Promise<CryptoKey> {
const pem = await (await fetch('/message_public.pem')).text();
const base64 = pem.replace(/-----(BEGIN|END) PUBLIC KEY-----/g, '').replace(/\s/g, '');
return window.crypto.subtle.importKey(
'spki',
fromBase64(base64),
{ name: 'RSA-OAEP', hash: 'SHA-1' },
false,
['encrypt']
);
}
async function encryptForServer(message: string): Promise<string> {
const publicKey = await getServerPublicKey();
const encrypted = await window.crypto.subtle.encrypt(
{ name: 'RSA-OAEP' },
publicKey,
new TextEncoder().encode(message)
);
return toBase64(new Uint8Array(encrypted));
}
const SendMessage = () => {
const [message, setMessage] = useState('');
const handleSendMessage = async () => {
try {
// Encrypt the message on the client-side using Web Crypto API
const encryptedMessage = await encryptForServer(message);
const response = await axios.post('/api/messages', { encryptedMessage });
console.log(response.data);
} catch (error) {
console.error('Error sending message:', error);
}
};
return (
<div>
<input type="text" value={message} onChange={(e) => setMessage(e.target.value)} />
<button onClick={handleSendMessage}>Send Message</button>
</div>
);
};
export default SendMessage;
Register the endpoint in routes/api.php with Route::post('/messages', [MessageController::class, 'store']);. The relative URLs in the React code (/message_public.pem and /api/messages) assume the app is served from the same origin as your Laravel backend; during development, configure Vite’s server.proxy option to forward these paths to Laravel.
Remember to handle errors and edge cases properly when integrating with your backend service.
Securing Sensitive Data: Handling Errors and Edge Cases
When implementing encryption on the client-side using the Web Crypto API, it’s essential to handle errors and edge cases properly to ensure a seamless user experience. Let’s take a look at some best practices for securing sensitive data.
Error Handling
import { Component, type ReactNode } from 'react';
class App extends Component<{ children?: ReactNode }> {
async encryptData(data: string, key: CryptoKey) {
try {
const iv = window.crypto.getRandomValues(new Uint8Array(12));
const encrypted = await window.crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
new TextEncoder().encode(data)
);
return { iv, encrypted };
} catch (error) {
console.error('Encryption error:', error);
throw new Error('Failed to encrypt data');
}
}
render() {
if (!window.isSecureContext || !window.crypto?.subtle) {
throw new Error('Web Crypto API not supported');
}
// ... rest of the code ...
return this.props.children;
}
}
In this example, we’re catching any errors that occur during encryption and logging them to the console. We’re also throwing a custom error with a descriptive message to handle cases where encryption fails.
Edge Cases
One edge case to consider is when the user’s browser doesn’t support the Web Crypto API. We can detect this by checking if window.crypto.subtle exists, as shown above. Because render() throws in that case, wrap the component in an error boundary so the user sees a fallback message instead of a blank screen.
Another edge case is when the page is served over plain HTTP: crypto.subtle is only available in secure contexts (HTTPS or localhost), so it will be undefined even in browsers that support the API. Decryption can also fail with an OperationError when the data has been altered or the wrong key is used. In these cases, we should display a friendly error message to the user explaining why encryption failed.
By handling these potential issues, you’ll ensure that your React app remains secure and functional even in cases where things don’t go as planned. With proper error handling and edge case management, you’ve completed the final step towards securely implementing Web Crypto API with React.
Frequently Asked Questions
How do I install the necessary dependencies for implementing client-side encryption in my React app?
Create the project with npm create vite@latest using the react-ts template, which already includes TypeScript, then run npm install axios and install react-devtools for debugging purposes. Check your tsconfig.app.json file against the recommended configuration.
What is the Web Crypto API, and how does it help with encryption in my React application?
The Web Crypto API is a JavaScript API that provides functions for basic cryptographic operations in web applications. It allows developers to perform encryption, decryption, digital signing, and other tasks directly within the browser.
How do I import and use the Web Crypto API in my React application?
You don’t need to import the Web Crypto API: it’s available globally as window.crypto, and its cryptographic operations are on crypto.subtle. It only works in secure contexts (HTTPS or localhost). Use crypto.subtle.generateKey() to generate a key.
What are some common errors I might encounter when implementing client-side encryption in my React app?
Be sure to handle errors properly, such as checking if the Web Crypto API is supported by the browser and handling any exceptions that may occur during key generation or encryption/decryption operations.
