RESTful web services are typically protected by HTTP over TLS, better known as HTTPS. TLS encryption secures the communication between two parties by encrypting traffic in transit.

For most services, this level of encryption is sufficient. However, an additional encryption layer can offer enhanced protection in scenarios involving sensitive communications —such as financial transactions, healthcare data exchanges, or confidential business information.

This added layer helps ensure that sensitive data remains secure and tamper-proof even if the messages are intercepted or decrypted at the endpoints.

In this article, we'll explore how to securely exchange messages between two parties, achieving confidentiality through encryption and ensuring integrity and authentication with digital signatures.

TL;DR The full code of the solution is available on GitHub.

Cryptographic Keys

Before encrypting messages, we'll start by discussing cryptographic keys. For this solution, we'll utilize both symmetric and asymmetric encryption.

Symmetric encryption involves a single key used for both encryption and decryption, while asymmetric encryption uses a pair of keys: a public key and a private key.

In our example, we'll combine symmetric AES keys with asymmetric RSA keys, leveraging each for their strengths:

  • Generate a random AES key (Data Key) to encrypt the message
  • Encrypt the Data Key with the receiver's RSA public key so only the intended recipient can decrypt it using the corresponding private key
  • Sign the encrypted message with the sender's RSA private key, enabling the receiver to verify the sender's identity and detect any tampering using the sender's public key

The following diagram illustrates how the sender generates a secure message:

None
Creation of the secure message by the sender

And here's how the receiver consumes the secure message:

None
Consumption of the secure message by the receiver

This setup requires both parties to pre-share their public keys, which is the intended purpose of public keys.

Cryptographic Functions

Now that we have introduced encryption, decryption, and digital signatures, let's get hands-on with some code.

const crypto = require('crypto');

// Function to encrypt a message using AES
function encryptMessage(message, aesKey, iv) {
    const cipher = crypto.createCipheriv('aes-128-cbc', aesKey, iv);
    let encrypted = cipher.update(message, 'utf8', 'hex');
    encrypted += cipher.final('hex');
    return encrypted;
}

// Function to decrypt a message using AES
function decryptMessage(encryptedMessage, aesKey, iv) {
    const decipher = crypto.createDecipheriv('aes-128-cbc', aesKey, Buffer.from(iv, 'hex'));
    let decrypted = decipher.update(encryptedMessage, 'hex', 'utf8');
    decrypted += decipher.final('utf8');
    return decrypted;
}

// Function to encrypt the AES key with RSA
function encryptAESKeyWithRSA(aesKey, publicKey) {
    return crypto.publicEncrypt(publicKey, aesKey).toString('base64');
}

// Function to decrypt the AES key with RSA
function decryptAESKeyWithRSA(encryptedAESKey, privateKey) {
    return crypto.privateDecrypt(privateKey, Buffer.from(encryptedAESKey, 'base64'));
}

// Function to sign data with a private RSA key
function signData(data, privateKey) {
    const sign = crypto.createSign('SHA256');
    sign.update(data);
    sign.end();
    return sign.sign(privateKey, 'base64');
}

// Function to verify a digital signature
function verifySignature(data, signature, publicKey) {
    const verify = crypto.createVerify('SHA256');
    verify.update(data);
    verify.end();
    return verify.verify(publicKey, signature, 'base64');
}

module.exports = {
    encryptMessage,
    decryptMessage,
    encryptAESKeyWithRSA,
    decryptAESKeyWithRSA,
    signData,
    verifySignature,
};

For this example, we'll use Node.js, TypeScript, and the built-in crypto module to create reusable functions.

When handling encryption and signatures, use well-established libraries and don't reinvent the wheel.

Sending Messages

With our utility functions ready, we can now send an encrypted message.

const express = require("express");
const crypto = require("crypto");
const axios = require("axios");
const {
  encryptAESKeyWithRSA,
  encryptMessage,
  signData,
} = require("./crypto-utils");
const { readFileAsString } = require("./file-utils");

const app = express();
app.use(express.json());

// Load keys from environment or secure storage (for demonstration)
const api1PrivateKey = readFileAsString(process.env.API_1_PRIVATE_KEY_FILE);
const api2PublicKey = readFileAsString(process.env.API_2_PUBLIC_KEY_FILE);

// Load receiver API URL
const api2ReceiveMessageURL = process.env.API_2_RECEIVE_MESSAGE_URL;

// Reusable function for sending secure messages
async function sendSecureMessage(url, message, headers) {
  try {
    return await axios.post(url, message, { headers });
  } catch (error) {
    throw new Error(`Failed to send message: ${error.message}`);
  }
}

// Send a message to API 2
app.post("/send-message", async (req, res) => {
  try {
    const { message } = req.body;
    if (!message) {
      return res.status(400).json({ error: "Message is required" });
    }

    // Generate AES key and IV for encryption
    const aesKey = crypto.randomBytes(16);
    const iv = crypto.randomBytes(16);

    // Encrypt the message using the AES key
    const encrypted = encryptMessage(message, aesKey, iv);

    // Encrypt the AES key with API 2's public RSA key
    const encryptedAESKey = encryptAESKeyWithRSA(aesKey, api2PublicKey);

    // Sign the encrypted message + IV with API 1's private RSA key for authenticity
    const signature = signData(encrypted + iv.toString("hex"), api1PrivateKey);

    // Send the encrypted message to API 2
    await sendSecureMessage(
      api2ReceiveMessageURL,
      {
        iv: iv.toString("hex"),
        encrypted,
      },
      {
        "X-Encryption-Key": encryptedAESKey,
        "X-Signature": signature,
      }
    );

    res.json({ status: "Message sent" });
  } catch (error) {
    console.error("Server side error while sending secure message.", error);
    res.status(500).send();
  }
});

app.listen(3000, () => {
  console.log("API 1 running on port 3000");
});

For demonstration, we have exposed an endpoint that receives a message and sends it encrypted and signed to the other end of the communication.

Key points in the implementation:

  • The message is encrypted with a random AES-128 key and a random Initialization Vector (IV), and sent in the request body
  • This AES key is then encrypted with the receiver's public key and included in the request headers as X-Encryption-Key
  • A digital signature is generated for the encrypted message and IV and is sent in the X-Signature header

Receiving Messages

Now that we can send encrypted messages, let's see how to handle them, exposing an endpoint to receive a message.


const express = require("express");
const {
  decryptAESKeyWithRSA,
  decryptMessage,
  verifySignature,
} = require("./crypto-utils");
const { readFileAsString } = require("./file-utils");

const app = express();
app.use(express.json());

// Load keys from environment or secure storage (for demonstration)
const api2PrivateKey = readFileAsString(process.env.API_2_PRIVATE_KEY_FILE);
const api1PublicKey = readFileAsString(process.env.API_1_PUBLIC_KEY_FILE);

// Receive a message from API 1
app.post("/receive-message", (req, res) => {
  try {
    // Get the secure message details
    const { iv, encrypted } = req.body;
    const encryptedAESKey = req.header("X-Encryption-Key");
    const signature = req.header("X-Signature");

    // Verify the signature of the encrypted message + IV with API 1's public key
    const isSignatureValid = verifySignature(
      encrypted + iv,
      signature,
      api1PublicKey
    );

    if (!isSignatureValid) {
      console.warn("Invalid signature", signature);
      return res.status(403).json({ error: "Invalid signature" });
    }

    // Decrypt the AES key with API 2's private RSA key
    const aesKey = decryptAESKeyWithRSA(encryptedAESKey, api2PrivateKey);

    // Decrypt the message using the decrypted AES key
    const decryptedMessage = decryptMessage(encrypted, aesKey, iv);

    res.json({ decryptedMessage });
  } catch (error) {
    console.error("Server side error while processing secure message.", error);
    res.status(500).send();
  }
});

app.listen(4000, () => {
  console.log("API 2 running on port 4000");
});

Key points in the implementation:

  • Extract the encrypted message, IV, encryption key, and signature from the request
  • Verify the message's signature with the sender's public key to confirm their identity and detect any tampering during transit
  • If the signature is valid, decrypt the encryption key with the receiver's private key, and use the decrypted Data Key to decrypt the message

Full Demo

To bring it all together, we'll create two RESTful APIs that exchange secure messages with one another.

To test the solution, download the code from the GitHub repository and follow the instructions in the Getting Started section. You can launch the Express.js RESTful APIs either using Node.js or Docker.

Using the /send-message endpoint on either API will transmit a secure message to the other. The debug messages will trace the full flow for sending and receiving secure messages on each API.

You can use the provided Postman collection and environment as a playground to test the solution.

Final Thoughts

This solution is not a replacement for TLS encryption, which is essential for securing the entire communication path. However, TLS-encrypted data can be exposed when decrypted by security appliances or logged by intermediary systems like proxies or load balancers.

The additional layer of encryption and signing protects the message itself, not just the communication channel. This ensures confidentiality, integrity, and authenticity, so even if communication is intercepted, messages remain secure and unaltered.

While we've implemented this example using Node.js, TypeScript, and Express.js, the underlying principles can be applied in other languages, frameworks, and communication scenarios.

The security strength of this solution aligns with the NIST guidelines on acceptable algorithms, key lengths, and projected time frames for the next years (see special publications 800–131A and 800–57 Part 1). Although we've applied AES-128 in CBC mode and RSA-2048, different algorithms may be used based on specific needs.

In some sectors, message-level encryption may be required for compliance or audit purposes. However, it might be unnecessary in simpler cases where data sensitivity is low or the potential risks don't justify the added complexity.

Ultimately, security decisions are about striking the right balance between risk management and system complexity.