makepkgBuilding an 8-Layer Security Architecture for a $15 Hardware Device When you're building a...
When you're building a hardware password manager, security isn't optional—it's the entire point.
But how do you secure a $15 ESP32 device that stores sensitive data? The answer: defense in depth.
In this post, I'll break down the 8-layer security architecture I built for SecureGen, a hardware TOTP authenticator and password manager. Each layer defends against different attack types, ensuring that breaching one layer doesn't compromise the system.

Single-layer security is a single point of failure.
If your only protection is encryption and the key gets leaked, game over. If your only protection is authentication and credentials get phished, you're done.
Defense in depth means attackers need to bypass multiple independent layers. Each layer:
Let's break down each layer.
Passwords and TOTP secrets need to be encrypted. But if you hardcode encryption keys, anyone with the source code can decrypt your data.
Generate unique encryption keys dynamically using Elliptic Curve Diffie-Hellman (ECDH) with the P-256 curve.
// Generate ephemeral key pair
mbedtls_ecdh_context ctx;
mbedtls_ecdh_setup(&ctx, MBEDTLS_ECP_DP_SECP256R1); // P-256
mbedtls_ecdh_gen_public(&ctx, &olen, public_key, 65,
mbedtls_ctr_drbg_random, &ctr_drbg);
// Compute shared secret
mbedtls_ecdh_calc_secret(&ctx, &olen, shared_secret, 32,
mbedtls_ctr_drbg_random, &ctr_drbg);
// Derive encryption key
mbedtls_sha256(shared_secret, 32, encryption_key, 0);
What this prevents: Man-in-the-middle (MITM) attacks
Even if someone intercepts the traffic during key exchange, they can't compute the shared secret without the private keys. And since keys are ephemeral (generated per session), they can't be reused.
Before ECDH:
[Attacker intercepts traffic]
→ Attacker sees: Encrypted data with static key
→ Attacker bruteforces key offline
→ Game over
After ECDH:
[Attacker intercepts traffic]
→ Attacker sees: Public keys (useless without private)
→ Attacker can't compute shared secret
→ Encrypted data remains secure
Bluetooth (BLE) has built-in encryption, but it's not enough for sensitive data like passwords.
Double encryption: AES-128 at the BLE layer + AES-256 at the application layer.
// BLE layer (automatic)
esp_ble_gap_set_security_param(ESP_BLE_SM_AUTHEN_REQ_MODE,
&auth_req, sizeof(uint8_t));
auth_req = ESP_LE_AUTH_REQ_SC_MITM_BOND; // AES-128
// Application layer (manual)
void encrypt_password(const char* password, uint8_t* output) {
mbedtls_aes_context aes;
mbedtls_aes_setkey_enc(&aes, session_key, 256); // AES-256
uint8_t iv[16];
esp_fill_random(iv, 16); // Random IV per encryption
mbedtls_aes_crypt_cbc(&aes, MBEDTLS_AES_ENCRYPT,
strlen(password), iv,
(uint8_t*)password, output);
}
What this prevents: Eavesdropping and packet sniffing
Even if an attacker breaks BLE encryption (unlikely but possible), they still face AES-256 application-layer encryption.
Single encryption:
[BLE vulnerability discovered]
→ Attacker breaks BLE encryption
→ Passwords visible in plaintext
→ Total compromise
Double encryption:
[BLE vulnerability discovered]
→ Attacker breaks BLE encryption
→ Attacker sees: More encrypted data (AES-256)
→ Passwords still secure
Static API endpoints are easy targets for automated scanners. Tools like dirb, gobuster, and nikto can discover them in seconds.
Obfuscate endpoint URLs using SHA-256 and change them on every device boot.
// Generate dynamic endpoint
String generate_endpoint(const char* base, uint32_t seed) {
char hash_input[64];
snprintf(hash_input, sizeof(hash_input), "%s-%u", base, seed);
uint8_t hash[32];
mbedtls_sha256((uint8_t*)hash_input, strlen(hash_input), hash, 0);
char endpoint[65];
for(int i = 0; i < 32; i++) {
sprintf(&endpoint[i*2], "%02x", hash[i]);
}
return String("/") + String(endpoint);
}
// Real endpoint on device
uint32_t boot_seed = esp_random();
String totp_endpoint = generate_endpoint("totp", boot_seed);
// Result: /a7f3c9e8b2d4f1a6c8e5d7f9b3a1c5e8d2f4a6b8c0e2d4f6a8b0c2e4f6a8b0c2
What this prevents: Automated API discovery and vulnerability scanning
Scanners expect endpoints like /api/totp or /passwords. When they get /a7f3c9e8b2d4f1a6..., they don't know what it does without manual analysis.
Before (static endpoints):
$ gobuster dir -u http://device.local -w wordlist.txt
Found: /api/totp
Found: /api/passwords
Found: /api/admin
[Automated exploit tools target these]
After (dynamic endpoints):
$ gobuster dir -u http://device.local -w wordlist.txt
No matches found
[Attacker needs to reverse-engineer endpoint generation]
HTTP headers reveal your tech stack. Headers like Server: nginx/1.18.0 or X-Powered-By: Express 4.17.1 tell attackers exactly which vulnerabilities to target.
Dynamically map and obfuscate all HTTP headers. Hide metadata.
// Header mapping (changes per session)
std::map header_map;
void init_header_obfuscation() {
header_map["Content-Type"] = "X-" + random_string(8);
header_map["Authorization"] = "X-" + random_string(8);
header_map["Server"] = "X-" + random_string(8);
}
void send_response(AsyncWebServerRequest *request, String content) {
AsyncWebServerResponse *response = request->beginResponse(200,
header_map["Content-Type"], content);
// Remove revealing headers
response->addHeader(header_map["Server"], "Unknown");
// Add fake misleading headers
response->addHeader("X-AspNetMvc-Version", "5.2.7"); // Not using ASP.NET
response->addHeader("X-Powered-By", "PHP/7.4.3"); // Not using PHP
request->send(response);
}
What this prevents: Tech stack fingerprinting and targeted exploits
Before:
HTTP/1.1 200 OK
Server: ESP-AsyncWebServer/1.2.3
Content-Type: application/json
X-ESP32-Chip: ESP32-D0WDQ6
[Attacker knows: ESP32, specific library versions]
[Searches for CVEs in those versions]
After:
HTTP/1.1 200 OK
X-k8f3a2: application/json
X-9e4c7: Unknown
X-AspNetMvc-Version: 5.2.7
X-Powered-By: PHP/7.4.3
[Attacker sees: Conflicting information]
[Wastes time targeting wrong stack]
Even without obvious headers, devices can be fingerprinted by response patterns, timing, and behaviors.
Inject fake headers, randomize response patterns, and make the device appear different on each request.
void add_fingerprint_confusion(AsyncWebServerResponse *response) {
// Random fake headers
const char* fake_frameworks[] = {
"Django/3.2.4", "Rails/6.1.3", "Laravel/8.40.0",
"Express/4.17.1", "Spring/5.3.8"
};
response->addHeader("X-Framework",
fake_frameworks[esp_random() % 5]);
// Randomize response time (within reason)
delay(esp_random() % 50 + 10); // 10-60ms
// Vary content encoding
if(esp_random() % 2) {
response->addHeader("Content-Encoding", "gzip");
}
// Change protocol hints
response->addHeader("Vary", "Accept-Encoding, User-Agent, Accept-Language");
}
What this prevents: Device fingerprinting and traffic analysis
Before:
Request 1: Response in 45ms, Server: ESP32
Request 2: Response in 44ms, Server: ESP32
Request 3: Response in 46ms, Server: ESP32
[Pattern: ESP32 device, consistent timing]
[Attacker: "I know what this is"]
After:
Request 1: Response in 23ms, Framework: Django
Request 2: Response in 51ms, Framework: Rails
Request 3: Response in 18ms, Framework: Laravel
[Pattern: Inconsistent, conflicting data]
[Attacker: "What the hell is this thing?"]
You want to know if someone is trying to break in. Passive defenses are invisible.
Create fake "vulnerable" endpoints that look tempting but are actually traps. Log all access attempts.
// Honey pot endpoints
server.on("/admin", HTTP_GET, [](AsyncWebServerRequest *request){
log_intrusion_attempt(request->client()->remoteIP().toString(),
"/admin", "Unauthorized access attempt");
// Return realistic fake data
String fake_response = R"({
"users": [
{"username": "admin", "role": "superuser"},
{"username": "root", "role": "admin"}
],
"version": "2.4.1",
"debug": true
})";
request->send(200, "application/json", fake_response);
});
server.on("/api/users", HTTP_GET, [](AsyncWebServerRequest *request){
// Check for SQL injection attempts
String query = request->arg("id");
if(query.indexOf("OR 1=1") != -1 || query.indexOf("'") != -1) {
log_intrusion_attempt(request->client()->remoteIP().toString(),
"/api/users", "SQL injection attempt detected");
}
request->send(200, "application/json", "[]"); // Empty response
});
// Path traversal trap
server.on("/../../etc/passwd", HTTP_GET, [](AsyncWebServerRequest *request){
log_intrusion_attempt(request->client()->remoteIP().toString(),
request->url(), "Path traversal attempt");
// Fake passwd file
String fake_passwd = "root:x:0:0:root:/root:/bin/bash\n"
"admin:x:1000:1000:Admin:/home/admin:/bin/bash\n";
request->send(200, "text/plain", fake_passwd);
});
What this prevents: Brute force attacks and automated exploitation
[Attacker scans device]
Attacker: "/admin endpoint found! Jackpot!"
[Accesses /admin]
[Device logs]
IP: 192.168.1.100
Endpoint: /admin
Time: 2026-02-15 14:23:11
User-Agent: python-requests/2.28.1
Action: Rate limited + alerted via web interface
[Attacker wastes time on fake data]
[You get early warning + attacker's IP]
Many automated scanners look for specific HTTP methods (GET, POST, DELETE) on endpoints.
Tunnel all requests through POST with an encrypted method field. Mask the real HTTP method.
// Client-side (JavaScript)
async function apiRequest(endpoint, method, data) {
const encrypted_method = await encrypt(method, session_key);
const response = await fetch(endpoint, {
method: 'POST', // Always POST
headers: {
'Content-Type': 'application/json',
'X-Method': encrypted_method // Real method hidden here
},
body: JSON.stringify(data)
});
return response.json();
}
// Server-side (ESP32)
server.on("/api/*", HTTP_POST, [](AsyncWebServerRequest *request){
String encrypted_method = request->header("X-Method");
String real_method = decrypt_method(encrypted_method);
if(real_method == "GET") {
handle_get(request);
} else if(real_method == "DELETE") {
handle_delete(request);
} else if(real_method == "PUT") {
handle_put(request);
}
});
What this prevents: Pattern-based automated attacks
Before:
$ curl -X DELETE http://device.local/api/passwords/1
Password deleted
[Automated scanner detects DELETE is enabled]
[Tries to delete everything]
After:
$ curl -X DELETE http://device.local/api/passwords/1
405 Method Not Allowed
$ curl -X POST http://device.local/api/passwords/1
Missing X-Method header
[Scanner confused: POST required but doesn't work normally]
[Manual analysis needed]
Attackers can infer information by measuring response times. For example:
This leaks information without ever succeeding.
Add random delays to all security-critical operations. Make timing unpredictable.
bool verify_credentials(const char* password, const char* pin) {
// Start timer
uint32_t start_time = millis();
// Actual verification
bool password_valid = constant_time_compare(password, stored_password);
bool pin_valid = constant_time_compare(pin, stored_pin);
bool result = password_valid && pin_valid;
// Calculate elapsed time
uint32_t elapsed = millis() - start_time;
// Target response time: 100-150ms
uint32_t target_time = 100 + (esp_random() % 50);
// Add delay to reach target (if needed)
if(elapsed < target_time) {
delay(target_time - elapsed);
}
return result;
}
// Constant-time string comparison (prevents timing leaks)
bool constant_time_compare(const char* a, const char* b) {
size_t len_a = strlen(a);
size_t len_b = strlen(b);
// Always compare full length (even if lengths differ)
size_t max_len = (len_a > len_b) ? len_a : len_b;
int result = len_a ^ len_b; // 0 if lengths match
for(size_t i = 0; i < max_len; i++) {
char char_a = (i < len_a) ? a[i] : 0;
char char_b = (i < len_b) ? b[i] : 0;
result |= char_a ^ char_b;
}
return result == 0;
}
What this prevents: Timing side-channel attacks and password length inference
Before (no timing protection):
# Attacker script
import requests
import time
passwords = ["a", "ab", "abc", "abcd", ...]
for pwd in passwords:
start = time.time()
response = requests.post("/auth", json={"password": pwd})
elapsed = time.time() - start
print(f"{pwd}: {elapsed:.3f}s")
# Output:
# a: 0.005s ← Wrong immediately
# ab: 0.005s ← Wrong immediately
# abc: 0.007s ← Took slightly longer! (closer match)
# abcd: 0.005s ← Back to fast
# Conclusion: Real password starts with "abc"
After (with timing protection):
# Attacker script (same)
# Output:
# a: 0.123s
# ab: 0.147s
# abc: 0.108s
# abcd: 0.135s
# Conclusion: ??? (no pattern)
Here's how all 8 layers work in a real scenario:
1. Attacker scans device
→ Layer 3: Dynamic endpoints confuse scanner
→ Layer 4: Fake headers mislead fingerprinting
2. Attacker finds /admin endpoint (honey pot)
→ Layer 6: Access logged, IP recorded
→ Fake data returned, attacker wastes time
3. Attacker tries SQL injection
→ Layer 6: Honey pot detects pattern, logs attempt
→ Layer 5: Randomized responses confuse automation
4. Attacker intercepts BLE traffic
→ Layer 1: ECDH-generated keys are useless without private key
→ Layer 2: Double encryption still protects data
5. Attacker attempts timing attack on auth
→ Layer 8: Random delays hide password validation timing
→ No information leaked
6. Attacker tries brute force via web API
→ Layer 7: Method tunneling breaks automated tools
→ Layer 8: Consistent random timing prevents optimization
Result: Attack fails, you have logs of attempt, attacker wasted hours
Each layer should target different attack vectors. Having 8 encryption layers doesn't help if they all use the same algorithm and key.
Good layering:
Bad layering:
No system is unbreakable. But security can make attacks:
For a $15 DIY device storing personal passwords, being harder to crack than a corporate server is enough deterrent.
The honey pot layer isn't about stopping attacks—it's about knowing they're happening. Early warning is invaluable.
Making the code public doesn't weaken security. The architecture still requires:
"Security through obscurity" is not security. Security through architecture is.
| Layer | Code Complexity | Performance Impact | Maintenance |
|---|---|---|---|
| ECDH | Medium (mbedTLS) | Low (once per session) | Low |
| Session Encryption | Low (built-in) | Low | Low |
| Dynamic Endpoints | Low | None | Low |
| Header Obfuscation | Low | None | Medium |
| Anti-Fingerprinting | Low | Low (~50ms) | Low |
| Honey Pot | Low | None | Low |
| Method Tunneling | Medium | Low | Medium |
| Timing Protection | Medium | Medium (~100ms) | Low |
Total added overhead: ~150ms per critical operation
Memory overhead: ~15KB for security code
Development time: ~2 weeks (with testing)
Current architecture is strong, but there's always room for improvement:
Building secure embedded systems doesn't require enterprise budgets or closed-source magic. It requires:
The 8-layer architecture in SecureGen demonstrates that even a $15 ESP32 device can implement defense-in-depth security rivaling commercial products.
The full source code is available on GitHub: github.com/makepkg/SecureGen
Every security decision, every line of code, every trade-off is documented and auditable. Because verifiable security is the only real security.
Questions? Comments? Drop them below! I'm happy to discuss specific layers, trade-offs, or alternative approaches.
Building something similar? I'd love to hear about your security architecture!
Your 8-layers infographic (already have)
Create these in VS Code with dark theme, screenshot:
BEFORE:
GET /api/totp
GET /api/passwords
DELETE /api/passwords/1
AFTER:
POST /a7f3c9e8b2d4f1a6...
POST /9e4f1b8c3a7d2f5e...
POST /c3f7a9e1b5d8f2a4...
BEFORE:
Server: ESP-AsyncWebServer/1.2.3
X-ESP32-Chip: ESP32-D0WDQ6
Content-Type: application/json
AFTER:
X-k8f3a2: application/json
X-AspNetMvc-Version: 5.2.7
X-Powered-By: PHP/7.4.3
Simple graph showing response times:
Simple graph showing response times: