Encrypted Mobile API Debugging? No More Copying Ciphertext Across Multiple Tools
I'm an independent developer who spends a lot of time debugging mobile H5 pages through a local HTTPS proxy. The part that never gets easier: the request body looks fine in the proxy, but every important field is still
I'm an independent developer who spends a lot of time debugging mobile H5 pages through a local HTTPS proxy.
The part that never gets easier: the request body looks fine in the proxy, but every important field is still encrypted. You copy a Base64 blob into a Node REPL, tweak a phone number, re-encode it, paste it into Postman, realize the client also signs headers, close Postman, open Charles againβ¦
Existing tools are good at transport (TLS MITM) or API replay (Postman/Insomnia). Neither really owns the loop of capture β decrypt β edit in context β re-encrypt β mock or resend. That's the gap I kept hitting for months.
This post walks through that manual workflow first β then the layered config model I used to automate it.
Two different "decryptions"
Before any app-layer trickery, you need plaintext HTTP.
| Layer | What you get | Typical setup |
|---|---|---|
| Transport (HTTPS MITM) | Readable HTTP headers/body | Proxy + trusted root CA on device + SSL decrypt allowlist |
| Application (param transform) | Readable business fields inside JSON/query | Rules that decode Base64/AES/etc. on specific keys |
If your capture list is full of CONNECT tunnels and empty bodies, fix layer 1 first. Param transform only helps once you can already see something like:
{
"username": "alice",
"token": "YWxpY2U6c2VjcmV0"
}
Pain point 1: Decrypting once is easy; doing it fifty times a day is not
Here's the manual loop I used to run for a single login field:
1. Capture in the proxy
POST /v1/login HTTP/1.1
Host: api.example.com
Content-Type: application/json
{"username":"alice","token":"YWxpY2U6c2VjcmV0"}
2. Decode in a one-off script
// decode.mjs β run every time you need to inspect a value
const token = "YWxpY2U6c2VjcmV0";
console.log(Buffer.from(token, "base64").toString("utf8"));
// alice:secret
3. Change the value and encode back
const plain = "alice:new-secret";
console.log(Buffer.from(plain, "utf8").toString("base64"));
// YWxpY2U6bmV3LXNlY3JldA==
4. Paste into Postman β which doesn't know your client's header signing, cookie jar, or device fingerprint.
5. Give up and edit in Charles β but Charles has no first-class "this JSON key is always Base64" rule that also feeds your mock matcher.
The userId, phone, device token⦠each field repeats the same five steps. Teams share Postman collections but rarely share the half-broken decrypt scripts that live in someone's ~/tmp.
Pain point 2: Mocking on ciphertext is fragile
Say you want to mock "phone = 13800138000" on an encrypted API.
Without transform rules you either:
- Pre-compute ciphertext offline and hard-code it in the mock (breaks when keys rotate), or
- Write a Charles rewrite script that decrypts before matching (works, but nobody on the team wants to maintain it)
What you actually want is: match on plaintext, send ciphertext. That implies decrypt-before-match in the proxy pipeline:
incoming request
β apply param transform (derive plaintext)
β mock matcher reads plaintext features
β (optional) re-encrypt before forwarding
Order matters. If mock runs on raw body only, your 13800138000 rule never fires.
Pain point 3: AES is where copy-paste really breaks
Base64 is annoying. AES-CBC with a fixed IV is where I lost afternoons.
Minimal Node version that must match the mobile client byte-for-byte:
const crypto = require("crypto");
function decryptField(b64Cipher) {
const key = Buffer.from("1234567890123456", "utf8"); // 16 bytes
const iv = Buffer.from("abcdefghijklmnop", "utf8"); // 16 bytes
const decipher = crypto.createDecipheriv("aes-128-cbc", key, iv);
decipher.setAutoPadding(true); // PKCS7
let plain = decipher.update(b64Cipher, "base64", "utf8");
plain += decipher.final("utf8");
return plain;
}
function encryptField(plain) {
const key = Buffer.from("1234567890123456", "utf8");
const iv = Buffer.from("abcdefghijklmnop", "utf8");
const cipher = crypto.createCipheriv("aes-128-cbc", key, iv);
let out = cipher.update(plain, "utf8", "base64");
out += cipher.final("base64");
return out;
}
One wrong assumption β key as hex vs utf8, IV prepended to ciphertext, GCM auth tag placement β and you get garbage. Now multiply that by three environments (staging keys, prod keys, feature-flag keys).
Multi-step pipelines are worse:
// common pattern: base64 wrapper around AES output
function convertDecrypt(input) {
const inner = Buffer.from(input, "base64").toString("utf8");
return decryptField(inner);
}
That's when I stopped trusting one-liner snippets and started wanting versioned, exportable rules tied to URL patterns.
Pain point 4: Postman isn't on the phone's code path
Postman is great for contract testing. It's a poor stand-in when:
- The app adds a timestamp + HMAC over the sorted body
- Tokens come from a previous capture-only handshake
- You need to see what this WebView actually sent, then nudge one field
The useful workflow is: same captured request, same headers, one edited field, resend through the proxy. Not "reconstruct the request from docs."
A cleaner model: separate "how" from "where"
The model I ended up with splits config into two layers β conversion rules (how to decrypt/encrypt) and param transform rules (which request + which param key):
Conversion rule β HOW to decrypt/encrypt (algorithm or script)
β referenced by
Transform rule β WHICH request + WHICH param key
β produces
paramTransformDerived (plaintext sidecar β original capture untouched)
Why keep the raw capture?
- Audit: you still see what the client actually sent
- Diff: compare ciphertext drift across builds
- Safety: failed transforms don't corrupt stored payloads
Example transform target for the login JSON above:
// conceptual config β not a literal file format
{
"match": {
"url": [{ "key": "host", "value": "api.example.com" }],
"bodyParams": [{ "key": "token", "value": "*", "bodyKind": "json" }]
},
"apply": {
"ruleId": "cr_login_b64",
"target": { "source": "body", "key": "token", "bodyKind": "json" }
}
}
With a builtin Base64 conversion rule on cr_login_b64, every matching capture automatically gets a derived plaintext row β no REPL, no clipboard.
Script escape hatch (when builtin isn't enough)
For custom signing, chained transforms, or calling an internal decrypt API:
function convertDecrypt(input, request) {
// input: the param string; request: read-only snapshot (host, headers, body, query)
return util.crypto.aesDecrypt({
ciphertext: input,
key: "0123456789abcdef",
iv: "0123456789abcdef",
keyEncoding: "utf8",
ivEncoding: "utf8",
inputEncoding: "base64",
outputEncoding: "utf8",
});
}
function convertEncrypt(input, request) {
return util.crypto.aesEncrypt({
plaintext: input,
key: "0123456789abcdef",
iv: "0123456789abcdef",
keyEncoding: "utf8",
ivEncoding: "utf8",
plaintextEncoding: "utf8",
outputEncoding: "base64",
});
}
convertEncrypt is optional β only needed if you want to edit plaintext and send it back (mock tamper, resend drawer). MD5/SHA/HMAC-style rules are view-only by design.
That's the full layered model. I built a desktop proxy tool around it so the decrypt β edit β re-encrypt loop stays inside the capture workflow instead of bouncing between a REPL and three other apps.
What I wired this into β DevPeek
The app is called DevPeek (Electron + Vue 2 main/renderer, Node proxy in the middle). I used the pain points above as a checklist. Three places param transform actually matters:
Capture detail β a dedicated tab shows ciphertext, derived plaintext, and success/failure per rule. Day-to-day debugging reads plaintext first.
Mock β matchers can use decrypted features; tamper UI edits plaintext and re-encrypts on continue. Fixes pain point 2 without maintaining Charles scripts.
Resend / debug API drawer β start from the captured request, edit a JSON field as plain text, send through the proxy with auto re-encrypt. Fixes pain point 4.
Builtins cover Base64, AES-ECB/CBC/GCM, DES/3DES, RSA; scripts run sandboxed with helpers for crypto, compression, and HTTP if you need a remote decrypt service.
Core desktop features (proxy, SSL decrypt, param transform, mock, search) are free β no subscription wall on the workflow described here.
I'm curious β what's the most annoying encrypted param debugging workflow you've had to deal with? Feel free to share your pain points below; I may add native support for those cases in future updates.
I've released Windows builds for anyone who wants to test this param transform workflow out β macOS and Linux builds are actively in development based on community feedback.
Docs: Param transform guide
Try it: devpeek.ypgao.com
Troubleshooting cheatsheet
| Symptom | Likely cause |
|---|---|
| Body still opaque gibberish | HTTPS MITM not set up (cert / decrypt allowlist) |
| Transform tab empty | Rule disabled or URL/body pattern mismatch |
builtin_error |
Key/IV encoding, padding, or algorithm mismatch vs client |
| Mock never matches plaintext | Transform didn't run before mock; check rule order |
| Field read-only in resend UI | Re-encrypt not enabled, or algorithm is one-way (hash/HMAC) |
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.