For years, Postman was our go-to tool for testing and working with APIs. Over time, however, we ran into several limitations:

  • A shift to a cloud-first model with collections and environment variables stored on Postman servers
  • Reduced control over sensitive data such as tokens and keys
  • Licensing changes that moved many features into paid plans

Our goal was to find an alternative that worked fully locally, supported existing collections, and most importantly, allowed us to migrate pre-request and post-response scripts.

In this blog, I will walk you through how we tested three alternatives, Bruno, apiDog, and Insomnia, the challenges we faced, how we solved them, and which one was the best fit for this project.

Note on terminology

In this article, all references to API authorization headers and request signing use the placeholder name InternalAPI. We chose not to reveal the real product name to protect proprietary details while keeping the focus on the migration process itself.

The challenges

The migration turned out to be more difficult than expected. The biggest challenges were:

Scripts and Built-in Libraries

Postman comes with several JavaScript libraries available out of the box, which makes scripting much easier for testers:

  • CryptoJS is a cryptography library that provides hashing and HMAC functions. It is essential for building secure authentication headers. For example:
    var signature = CryptoJS.HmacSHA256(message, secretKey).toString(CryptoJS.enc.Base64);
    Without this, APIs requiring signed requests would reject calls.
  • btoa() is a function exposed by the Postman runtime (borrowed from browsers, not part of ECMAScript). It encodes data into Base64, often used for credentials or signing keys:
    var encoded = btoa(serviceId + ":" + secretKey);
    This is crucial when APIs expect authorization headers in Base64 format. In Node.js tools (Bruno, Insomnia), btoa() does not exist, and you must use Buffer.from(...).toString('base64').

In Postman, these utilities were always available with no setup. Alternatively, alternatives like Bruno, apiDog, and Insomnia either lacked them or required custom implementation (e.g., Node.js crypto or Buffer).

Syntax differences

Postman exposes global helper functions such as pm.environment.set or pm.variables.replaceIn. Alternatives rely on different APIs (bru.setEnvVar, environment.<var>, insomnia.environment), which meant scripts had to be rewritten.

Cryptographic libraries

Many of our requests depended on dynamically generated HMAC signatures combined with timestamps. Postman made this trivial because CryptoJS and btoa() were always at hand. Other tools required reimplementing these utilities, which added significant migration overhead.

Scope

This migration focused only on REST API requests. gRPC or GraphQL support (e.g., GQL) was not considered.

The solution

Example script in Postman (pre-request script)

postman.clearEnvironmentVariable("authHeader");
var authHeader = "InternalAPI ";

// get serviceId and secretKey from Postman’s environment variables
var serviceId = postman.getEnvironmentVariable("serviceId");
var secretKey = postman.getEnvironmentVariable("secretKey");

authHeader += btoa(sereviceID) + ":";
postman.setEnvironmentVariable("dateHeader", new Date().toISOString());


// build HMAC
var contentMDHeader = "";
var reqTime = new Date().toISOString();
var reqData = "";
if(request.data){
            var reqData = resolveEnvVars(request.data)
            console.log("request.data1",reqData);
            contentMDHeader = CryptoJS.MD5(reqData).toString(CryptoJS.enc.Base64);
        }

// Complete the Authentication header by building the message and 
// using it with the secret key
// to digest the HMAC SHA256 signed content and append it to 
// what we've already built
var Message = [request.method, contentTypeHeader,reqTime, 
    requestPath, projectHeaders, contentMDHeader, sereviceId].join("\n");

authHeader += CryptoJS.HmacSHA256(Message, CryptoJS.enc.Base64.parse(secretKey)).toString(CryptoJS.enc.Base64);
console.log("authHeader",authHeader);

In Postman, this worked immediately because both btoa() and CryptoJS are built-in.

Migration to Bruno

In Bruno, there is no access to postman.*. In Bruno, CryptoJS is built in. You don’t need to install or import it. The scripting runtime automatically exposes the CryptoJS object.

It works directly inside Pre-Request and Post-Response scripts. Example of pre-request script below:

const serviceId = bru.getEnvVar("serviceId");
const secretKeyB64 = bru.getEnvVar("secretKey"); 

function base64FromUtf8(str) {
  return CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(str || ""));
}
function splitUrl(rawUrl) {
  const schemeEnd = rawUrl.indexOf("://");
  let pathStart = rawUrl.indexOf("/", schemeEnd >= 0 ? schemeEnd + 3 : 0);
  if (pathStart === -1) pathStart = rawUrl.length;
  const base = rawUrl.substring(0, pathStart);
  const path = rawUrl.substring(pathStart) || "/";
  return { base, path };
}

function getOutgoingBody() {
  const b = req.getBody();
  if (b === undefined || b === null) return "";
  if (typeof b === "string") {
    const s = bru.interpolate(b);
    req.setBody(s);
    return s;
  }
    const s = JSON.stringify(b);
  req.setBody(s);
  return s;
}

const rawUrl = bru.interpolate(req.getUrl());
const { base: baseRequestPath, path: requestPath } = splitUrl(rawUrl);

// Date MUST match what we sign
const nowIso = new Date().toISOString();
req.setHeader("Date", nowIso);

const bodyStr = getOutgoingBody();
let contentTypeHeader = req.getHeader("content-type") || "";

let contentMDHeader = "";
if (bodyStr && bodyStr.length > 0) {
  
  const wordArray = CryptoJS.enc.Utf8.parse(bodyStr);
  contentMDHeader = CryptoJS.MD5(wordArray).toString(CryptoJS.enc.Base64);
  req.setHeader("Content-MD5", contentMDHeader); 
} else {
  // when body is empty → do not set Content-MD5
  req.removeHeader && req.removeHeader("Content-MD5");
}

//Build HMAC message
const method = req.getMethod();
const projectHeaders = ""; // leave empty as it was in Postman
const message = [
  method,
  contentTypeHeader,
  nowIso,
  requestPath,
  projectHeaders,
  contentMDHeader,
  serviceId
].join("\n");

//Sign
const signatureB64 = CryptoJS.HmacSHA256(
  message,
  CryptoJS.enc.Base64.parse(secretKeyB64)
).toString(CryptoJS.enc.Base64);

//Authorization
const authHeader = "InternalApi " + base64FromUtf8(serviceId) + ":" + signatureB64;
req.setHeader("Authorization", authHeader); 

What worked well in Bruno

  • Full local operation
    Bruno does not sync data to external servers, solving security concerns around tokens and keys.
  • Straightforward adaptation
    Switching from postman.* to bru.getEnvVar / bru.setEnvVar was easy and mechanical.
  • Reliable cryptography
    Node.js native crypto was available out of the box, making HMAC signing reliable and consistent.
  • Base64 support via Buffer
    Replacing btoa() with Buffer.from(…).toString("base64") worked without issues.
  • Fast migration time
    Once the migration pattern was established, scripts were adapted quickly and consistently.

Migration to apiDog

ApiDog is closer to Postman, so changes were minimal.

var sereviceID = pm.environment.get("serviceId");
var secretKey = pm.environment.get("secretKey");

// The time format used for InternalApi Authorization and Date request 
// headers is ISO-8601 without milliseconds
authHeader += btoa(sereviceID) + ":", pm.environment.set("dateHeader", (new Date).toISOString());
var requestUrl = resolveEnvVars(request.url);
var baseRequestPath = requestUrl.substr(0, requestUrl.indexOf("/InternalApi"));

var requestPath = requestUrl.replace(baseRequestPath, "");

var contentTypeHeader = resolveEnvVars(request.headers["content-type"]),
    projectHeaders = "",
    contentMDHeader = "",
    reqTime = (new Date).toISOString();
_.isEmpty(request.data) || (console.log("Request Body: ", request.data), contentMDHeader = CryptoJS.MD5(resolveEnvVars(request.data)).toString(CryptoJS.enc.Base64)),
    
var Message = [request.method, contentTypeHeader, reqTime, requestPath, projectHeaders, contentMDHeader, sereviceID].join("\n");
authHeader += CryptoJS.HmacSHA256(Message, CryptoJS.enc.Base64.parse(secretKey)).toString(CryptoJS.enc.Base64),
    
    pm.environment.set("authHeader", authHeader),
    pm.environment.set("contentMDHeader", contentMDHeader),
    pm.environment.set("hmac_string", Message),
    pm.environment.set("plainRequestData", request.data),
    pm.environment.set("dateHeader", reqTime);

Issues in apiDog

  • Environment management was basic
    Variables could be accessed through environment. <var>, but lacked Postman’s flexibility for complex use cases.
  • CryptoJS worked out of the box
    Reliance on this specific library meant less freedom to use other cryptographic approaches.
  • Scaling issues
    While fine for simple HMAC headers, more complex chaining of variables or nested requests was harder to maintain.

Migration to Insomnia

In Insomnia, neither CryptoJS nor Postman-like APIs were available.

const crypto = require("crypto");

var serviceId = insomnia.variables.get("serviceId");
var secretKey = insomnia.variables.get("secretKey");

// The time format used for InternalApi Authorization and Date request headers is ISO-8601 without milliseconds
authHeader += Buffer.from(serviceId).toString('base64') + ":";

insomnia.variables.set("dateHeader", (new Date()).toISOString()); // Set the Date header

// Get the request URL as a string
var requestUrl = insomnia.variables.get("Gateway-URL"); 

var baseRequestPath = requestUrl.substr(0, requestUrl.indexOf("/InternalApi"));

var requestPath = requestUrl.replace(baseRequestPath, "");

// Resolve content-type header
// Resolve variables in the Content-Type header if needed
var contentTypeHeader = resolveEnvVars(insomnia.request.headers["Content-Type"] || 'application/json');

var projectHeaders = "",
    reqTime = (new Date()).toISOString();

// Prepare the message for HMAC calculation
var Message = [
    insomnia.request.method, 
    contentTypeHeader, 
    reqTime, 
    requestPath, 
    projectHeaders, 
    serviceID
].join("\n");

// Function to calculate HMAC SHA-256 using Node.js crypto
async function calculateHMACSHA256(message, key) {
    try {
// Convert secret key to a buffer
        const cryptoKey = Buffer.from(key, 'utf8');  

// Convert message to buffer
        const messageData = Buffer.from(message, 'utf8');  

        const crypto = require('crypto');
        const hmac = crypto.createHmac('sha256', cryptoKey);
        hmac.update(messageData);

// Return the HMAC as Base64
        const hmacResult = hmac.digest('base64');

// Log the HMAC result  
        return hmacResult;
    } catch (err) {
        console.error("Error in HMAC calculation:", err); 
        throw err;  
    }
}

// Call the function to calculate HMAC and set authHeader
calculateHMACSHA256(Message, secretKey).then(hmacResult => {

// Append HMAC to the header
    authHeader += hmacResult; 
    
}).catch(err => {
    console.error("Error calculating HMAC:", err);  
});

Issues in Insomnia

  • Different API model
    Accessing requests and environments required learning new conventions (insomnia.request.*).
  • No built-in cryptography
    Everything requires Node.js crypto or third-party plugins.
  • Plugin dependency
    Plugins introduced inconsistency and extra setup across the team.
  • Time overhead
    Even though the migration was technically possible, it required significantly more effort compared to Bruno.

Automated Testing Support

In Postman:

pm.test("Status code is 200", function () {

pm.response.to.have.status(200);
});

In Bruno:

bru.test("Status code is 200", () => {

expect(res.status).toBe(200);
});

This means assertions are migrated along with requests. Collections in Bruno allow chaining multiple requests to validate complete flows, not just single calls.

In apiDog:

apiDog.test(\"Status code is 200\", () => {

expect(response.status).toBe(200);
});

In Insomnia:

insomnia.test(\"Status code is 200\", () => {

expect(response.status).to.equal(200);
});

Both apiDog and Insomnia support automated testing. Bruno offers the easiest migration path for Postman-style test scripts.

Why the pre-request script is so important

The pre-request script runs just before sending each request. Its role is to calculate dynamic authentication values that must be unique and valid at the exact moment the request is sent.

It means:

  1. Authentication & Authorization
    Only requests with correct signatures are accepted by the API.
  2. Integrity
    The signature covers the method, headers, timestamp, and URL, so any tampering is detected.
  3. Replay protection
    The use of a current timestamp prevents reusing the same signature multiple times.
  4. Freshness
    Every request carries a valid, just-in-time generated signature.

Key functions in Postman: btoa() and CryptoJS

Two functions were especially important in our Postman scripts:

  1. btoa()
    ○ Encodes binary data into Base64 format.
    ○ Commonly used for credentials or signing payloads before sending them in headers.

  2. CryptoJS
    ○ Provides cryptographic functions such as hashing and HMAC generation.
    ○ Essential for building secure Authorization headers.

Why this matters

Together, these utilities automate authentication flows in testing. They allow testers to generate fresh timestamps, sign requests, and encode headers dynamically before each request. Without them, test collections cannot mimic real production authentication and authorization processes.

Lessons learnt

  1. Scripts are the backbone of collections
    Requests alone are easy to migrate, but without working scripts, authentication and security logic break.
  2. Postman hides complexity
    Its built-in libraries like CryptoJS and btoa() give the illusion that advanced cryptography is simple. Migration showed us how much work goes into replicating the same logic outside Postman.
  3. Tool choice impacts migration effort
    apiDog offered near compatibility with Postman’s scripting model but lacked flexibility for scaling. Insomnia was powerful but too time-consuming. Bruno balanced both worlds with local-first simplicity and Node.js support.
  4. Consistency matters
    Bruno allowed us to create a repeatable migration pattern for all scripts, reducing the learning curve and keeping migrations fast.
  5. Security first
    Local-first tools like Bruno gave us peace of mind by ensuring no sensitive environment variables were ever synced to external servers.
  6. Postman is still excellent
    I want to stress that Postman remains a very strong tool. For projects where its cloud model is not an issue, it continues to be a reliable and productive choice.
  7. Knowledge expansion
    Through testing multiple alternatives, we not only found the best fit (Bruno) but also expanded our portfolio of tools, giving us flexibility in future projects.

Comparing the options

Bruno apiDogInsomnia
Ease of MigrationStraightforward once the migration pattern was established; quick adaptation.Very similar to Postman; some functions are recognized but flagged as deprecated.Migration is possible but requires more setup and plugins; slower process.
Script CompatibilityNo Postman API, but Node.js scripting (crypto, Buffer) worked well.Supports CryptoJS and btoa() out of the box; partially supports Postman syntax (deprecated).Lacks built-in Postman-style functions; requires Node.js crypto or third-party plugins.
Cryptography SupportReliable via Node.js crypto library; HMAC/Base64 easy to implement.Built-in CryptoJS simplified migration of signing logic.Must reimplement HMAC/Base64 using Node.js; plugins needed for some use cases.
Environment HandlingUses bru.getEnvVar / bru.setEnvVar; clean and explicit.Variables accessible via environment. <var>; less flexible for advanced scenarios.Different model (insomnia.environment); less intuitive for Postman users.
Local-first Security100% local storage of collections and environments.Local-first; data not synced externally.Local-first by default, though some features encourage sync across devices.
Performance / UXLightweight, fast, minimal UI; developer-friendly.Simple UI, close to Postman feel, but less polished.Rich plugin ecosystem, but heavier and sometimes slower.
ScalabilityGood for growing teams; scripting patterns are consistent.Fine for simple flows; scaling to more complex use cases can be limiting.Powerful and extensible, but higher maintenance overhead.
LicenseOpen Source (MIT License).Proprietary / Free version with limitations.Open Source (GPLv3).
OS SupportWindows, macOS, Linux.Windows, macOS (limited), Linux support.Windows, macOS, Linux.
VerdictBest balance: easy migration, reliable Node.js support, and secure local-first model.Easy for Postman users, but deprecated functions and limited scalability.Very flexible, but slower migration and higher maintenance burden.

Conclusion

We chose Bruno as the replacement for Postman in this project. It allowed us to migrate scripts quickly with minimal changes, thanks to its local-first approach, support for Node.js libraries like crypto, and easy handling of environment variables.

At the same time, Postman is still an outstanding tool and will continue to be used in other projects. The migration exercise not only solved a specific problem but also broadened our understanding of the ecosystem of API tools, strengthening our team’s portfolio and adaptability.

5/5 - (1 vote)