Firebase Realtime Database is a simple backend for IoT projects: a device writes sensor readings over HTTPS, and a web or mobile dashboard sees them update in real time. This guide connects an ESP32 with a DHT22 temperature and humidity sensor to the Realtime Database the secure way, with the device signed in as its own Firebase user so Security Rules control what it can write.
What I tested: the firmware compiles for the ESP32 with the Arduino ESP32 core 3.3.12, ArduinoJson 7.4 and the Adafruit DHT library (it uses 79% of the default program space). I did not flash it to hardware. Instead, a Node script made the exact same HTTP requests as the firmware against the Firebase Auth and Database emulators: sign-in, appending readings, updating the latest value, token refresh, and six cases the Security Rules must reject. All three tests passed.
The 2023 version of this post used the FirebaseArduino library and a database secret. The FirebaseArduino repository was archived in March 2025 and is no longer developed, and database secrets are a legacy credential that gives full access to the database instead of being limited by your rules. Anyone who extracts a secret from a device’s flash can read and wipe everything.
Architecture
| Piece | Role |
|---|---|
| ESP32 + DHT22 | Reads temperature and humidity every minute |
| Firebase Authentication | One email/password user per device; the device gets a short-lived ID token |
| Realtime Database | Stores readings under /devices/{uid} and pushes changes to dashboards |
| Security Rules | Let each device write only its own data, in a valid shape, with server time |
| Dashboard (web or app) | Listens to /devices/{uid}/latest and the readings history |
The device talks to Firebase over plain HTTPS using the Auth and Realtime Database REST APIs, so no Firebase-specific Arduino library is required. If you prefer a library, FirebaseClient is actively maintained, supports ESP32, ESP8266 and other 32-bit boards, and offers the same email/password user authentication.
Step 1: Set Up Firebase
- Create a Firebase project and a Realtime Database. Copy the database URL (for example
https://your-project-default-rtdb.firebaseio.com). - Enable the Email/Password sign-in provider in Authentication.
- Create one user per device, for example
[email protected]with a long random password. Its UID becomes the device ID. - Copy the Web API key from Project settings. It identifies the project and is not a secret on its own.
Step 2: Security Rules
Each device can append readings under its own UID, cannot edit or delete history, must send values in range, and must use the server’s timestamp. Only admins (users with an admin custom claim) can change a device’s configuration:
{
"rules": {
".read": false,
".write": false,
"devices": {
"$uid": {
".read": "auth != null && (auth.uid === $uid || auth.token.admin === true)",
"readings": {
".indexOn": ["ts"],
"$readingId": {
".write": "auth != null && auth.uid === $uid && !data.exists()",
".validate": "newData.hasChildren(['t', 'h', 'ts'])",
"t": { ".validate": "newData.isNumber() && newData.val() >= -40 && newData.val() <= 85" },
"h": { ".validate": "newData.isNumber() && newData.val() >= 0 && newData.val() <= 100" },
"ts": { ".validate": "newData.val() === now" },
"$other": { ".validate": false }
}
},
"latest": {
".write": "auth != null && auth.uid === $uid",
".validate": "newData.hasChildren(['t', 'h', 'ts'])",
"t": { ".validate": "newData.isNumber()" },
"h": { ".validate": "newData.isNumber()" },
"ts": { ".validate": "newData.val() === now" },
"$other": { ".validate": false }
},
"config": {
".write": "auth.token.admin === true",
"intervalSec": { ".validate": "newData.isNumber() && newData.val() >= 10" }
}
}
}
}
}Deploy with firebase deploy --only database. For the reasoning behind patterns like !data.exists(), $other and now, see Realtime Database Security Rules.
Step 3: Prepare the Arduino Environment
- Install the Arduino IDE (or arduino-cli) and add the Espressif board index:
https://espressif.github.io/arduino-esp32/package_esp32_index.json. - Install the esp32 platform by Espressif.
- Install the libraries ArduinoJson and DHT sensor library (it pulls in Adafruit Unified Sensor).
- Wire the DHT22: VCC to 3.3V, GND to GND, data to GPIO 4 (with a 10 kΩ pull-up if your module does not include one).
Step 4: The Firmware
Keep credentials in a separate header that you do not commit:
#pragma once
// Do not commit real values. One Firebase Auth user per device.
#define WIFI_SSID "your-wifi"
#define WIFI_PASSWORD "your-wifi-password"
#define API_KEY "your-web-api-key"
#define DEVICE_EMAIL "[email protected]"
#define DEVICE_PASSWORD "a-long-random-password"
#define DATABASE_URL "https://your-project-default-rtdb.firebaseio.com"The device must verify Firebase’s TLS certificates, not skip verification. When I inspected the live certificate chains, the Realtime Database and the Google sign-in endpoints chained to different Google roots (GTS Root R1 and R4), and both of those were cross-signed by the GlobalSign Root CA. Trusting that one root lets the device verify both endpoints. It expires on January 28, 2028, so plan a firmware update or a certificate bundle before then:
#pragma once
// GlobalSign Root CA, valid until 2028-01-28. Plan a firmware update before then.
static const char ROOT_CA[] =
"-----BEGIN CERTIFICATE-----\n"
"MIIDdTCCAl2gAwIBAgILBAAAAAABFUtaw5QwDQYJKoZIhvcNAQEFBQAwVzELMAkG\n"
... (full certificate in the file) ...
"HMUfpIBvFSDJ3gyICh3WZlXi/EjJKSZp4A==\n"
"-----END CERTIFICATE-----\n"
;The sketch signs in, refreshes its ID token before it expires (tokens last about an hour), reads its reporting interval from the database, and sends a reading each cycle: a POST to append to the history and a PUT to update the latest value. {".sv": "timestamp"} asks the server to fill in its own time:
// ESP32 -> Firebase Realtime Database over HTTPS, signed in as a Firebase Auth user
// so Security Rules apply. Libraries: ArduinoJson, DHT sensor library (Adafruit).
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <DHT.h>
#include "secrets.h" // WIFI_SSID, WIFI_PASSWORD, API_KEY, DEVICE_EMAIL, DEVICE_PASSWORD, DATABASE_URL
#include "root_ca.h" // GlobalSign Root CA (anchors both Google endpoints used here)
DHT dht(4, DHT22); // data pin 4
WiFiClientSecure tls;
String idToken, refreshToken, uid;
unsigned long tokenExpiresAt = 0; // millis()
unsigned long intervalMs = 60000; // overridden by /devices/{uid}/config/intervalSec
// POST JSON (or form data) and parse the JSON reply. Returns the HTTP status.
int postJson(const String& url, const String& body, JsonDocument& out, const char* type = "application/json") {
HTTPClient http;
http.begin(tls, url);
http.addHeader("Content-Type", type);
int code = http.POST(body);
if (code > 0) deserializeJson(out, http.getString());
http.end();
return code;
}
bool signIn() {
JsonDocument req, res;
req["email"] = DEVICE_EMAIL;
req["password"] = DEVICE_PASSWORD;
req["returnSecureToken"] = true;
String body;
serializeJson(req, body);
int code = postJson(String("https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=") + API_KEY, body, res);
if (code != 200) {
Serial.printf("Sign-in failed (%d)\n", code);
return false;
}
idToken = res["idToken"].as<String>();
refreshToken = res["refreshToken"].as<String>();
uid = res["localId"].as<String>();
tokenExpiresAt = millis() + (res["expiresIn"].as<String>().toInt() - 300) * 1000UL; // renew 5 min early
return true;
}
bool refreshIdToken() {
JsonDocument res;
int code = postJson(String("https://securetoken.googleapis.com/v1/token?key=") + API_KEY,
"grant_type=refresh_token&refresh_token=" + refreshToken, res,
"application/x-www-form-urlencoded");
if (code != 200) return signIn(); // refresh token revoked or invalid: sign in again
idToken = res["id_token"].as<String>();
refreshToken = res["refresh_token"].as<String>();
tokenExpiresAt = millis() + (res["expires_in"].as<String>().toInt() - 300) * 1000UL;
return true;
}
String dbUrl(const String& path) {
return String(DATABASE_URL) + path + ".json?auth=" + idToken;
}
void readConfig() {
HTTPClient http;
http.begin(tls, dbUrl("/devices/" + uid + "/config/intervalSec"));
if (http.GET() == 200) {
long sec = http.getString().toInt();
if (sec >= 10) intervalMs = sec * 1000UL;
}
http.end();
}
bool sendReading(float t, float h) {
JsonDocument doc;
doc["t"] = t;
doc["h"] = h;
doc["ts"][".sv"] = "timestamp"; // server time, required by the rules
String body;
serializeJson(doc, body);
HTTPClient http;
http.begin(tls, dbUrl("/devices/" + uid + "/readings")); // POST = append with a push ID
http.addHeader("Content-Type", "application/json");
int code = http.POST(body);
http.end();
http.begin(tls, dbUrl("/devices/" + uid + "/latest")); // PUT = overwrite latest value
http.addHeader("Content-Type", "application/json");
int code2 = http.PUT(body);
http.end();
Serial.printf("Reading t=%.1f h=%.1f -> %d / %d\n", t, h, code, code2);
return code == 200 && code2 == 200;
}
void setup() {
Serial.begin(115200);
dht.begin();
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
while (WiFi.status() != WL_CONNECTED) delay(500);
configTime(0, 0, "pool.ntp.org"); // TLS certificate checks need the correct time
while (time(nullptr) < 1700000000) delay(500);
tls.setCACert(ROOT_CA);
while (!signIn()) delay(10000);
readConfig();
}
void loop() {
if ((long)(millis() - tokenExpiresAt) >= 0) refreshIdToken();
float t = dht.readTemperature();
float h = dht.readHumidity();
if (isnan(t) || isnan(h)) {
Serial.println("Sensor read failed");
} else if (!sendReading(t, h)) {
refreshIdToken(); // most failures are expired tokens; retry next cycle
}
delay(intervalMs);
}The time sync with NTP before connecting matters: certificate validation fails if the ESP32 thinks it is still 1970.
$ arduino-cli compile --fqbn esp32:esp32:esp32 FirebaseSensor
Sketch uses 1038480 bytes (79%) of program storage space. Maximum is 1310720 bytes.
Global variables use 48992 bytes (14%) of dynamic memoryOne note from compiling on an Apple Silicon Mac: arduino-cli’s bundled ctags tool is an Intel binary and needs Rosetta. I compiled the same code from a .cpp file to avoid it; in the Arduino IDE on a typical setup this is not an issue.
Step 5: Test the Protocol Without Hardware
Because the device only makes HTTPS requests, you can test the whole flow on your computer against the Firebase emulators before touching hardware. This script makes the same calls as the firmware:
// Simulates the ESP32 firmware's HTTP calls against the Auth and Database emulators.
import { test } from 'node:test';
import assert from 'node:assert/strict';
const KEY = 'demo-key';
const AUTH = 'http://127.0.0.1:9099';
const DB = 'http://127.0.0.1:9000';
const NS = 'ns=demo-iot-default-rtdb';
const post = (url, body, type = 'application/json') =>
fetch(url, { method: 'POST', headers: { 'Content-Type': type }, body });
async function createDevice(email) {
const r = await post(`${AUTH}/identitytoolkit.googleapis.com/v1/accounts:signUp?key=${KEY}`,
JSON.stringify({ email, password: 'long-random-password', returnSecureToken: true }));
return r.json();
}
// Same request as signIn() in the sketch.
async function signIn(email) {
const r = await post(`${AUTH}/identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=${KEY}`,
JSON.stringify({ email, password: 'long-random-password', returnSecureToken: true }));
assert.equal(r.status, 200);
return r.json();
}
const db = (path, token, init = {}) =>
fetch(`${DB}${path}.json?${NS}&auth=${token}`, { ...init, headers: { 'Content-Type': 'application/json' } });
const reading = (t, h) => JSON.stringify({ t, h, ts: { '.sv': 'timestamp' } });
test('device signs in, appends readings and updates latest', async () => {
await createDevice('[email protected]');
const s = await signIn('[email protected]');
assert.ok(s.idToken && s.refreshToken && s.localId);
assert.ok(Number(s.expiresIn) > 300);
const append = await db(`/devices/${s.localId}/readings`, s.idToken, { method: 'POST', body: reading(23.4, 51) });
assert.equal(append.status, 200);
const { name } = await append.json();
assert.match(name, /^-/); // push ID
assert.equal((await db(`/devices/${s.localId}/latest`, s.idToken, { method: 'PUT', body: reading(23.4, 51) })).status, 200);
const latest = await (await db(`/devices/${s.localId}/latest`, s.idToken)).json();
assert.equal(latest.t, 23.4);
assert.ok(Math.abs(latest.ts - Date.now()) < 60_000); // server timestamp
});
test('rules: no writing as another device, no bad values, no editing history', async () => {
const a = await signIn('[email protected]');
await createDevice('[email protected]');
const b = await signIn('[email protected]');
assert.equal((await db(`/devices/${a.localId}/readings`, b.idToken, { method: 'POST', body: reading(20, 40) })).status, 401);
assert.equal((await db(`/devices/${a.localId}/readings`, a.idToken, { method: 'POST', body: reading(500, 40) })).status, 401);
assert.equal((await db(`/devices/${a.localId}/readings`, a.idToken, { method: 'POST', body: JSON.stringify({ t: 20, h: 40, ts: 1 }) })).status, 401);
const { name } = await (await db(`/devices/${a.localId}/readings`, a.idToken, { method: 'POST', body: reading(21, 45) })).json();
assert.equal((await db(`/devices/${a.localId}/readings/${name}`, a.idToken, { method: 'PUT', body: reading(99, 45) })).status, 401);
assert.equal((await db(`/devices/${a.localId}/config`, a.idToken, { method: 'PUT', body: JSON.stringify({ intervalSec: 1 }) })).status, 401);
assert.equal((await db(`/devices/${b.localId}`, a.idToken)).status, 401);
});
test('refresh token flow used by refreshIdToken()', async () => {
const s = await signIn('[email protected]');
const r = await post(`${AUTH}/securetoken.googleapis.com/v1/token?key=${KEY}`,
`grant_type=refresh_token&refresh_token=${s.refreshToken}`, 'application/x-www-form-urlencoded');
assert.equal(r.status, 200);
const j = await r.json();
assert.ok(j.id_token && j.refresh_token && j.expires_in);
assert.equal((await db(`/devices/${s.localId}/readings`, j.id_token, { method: 'POST', body: reading(22, 50) })).status, 200);
});$ npx firebase emulators:exec --only auth,database --project demo-iot "node --test test/device-protocol.test.mjs"
✔ device signs in, appends readings and updates latest
✔ rules: no writing as another device, no bad values, no editing history
✔ refresh token flow used by refreshIdToken()
ℹ pass 3The rules test checks that a device cannot write to another device’s path, cannot send an out-of-range temperature or a client-chosen timestamp, cannot overwrite an existing reading, cannot change its own config, and cannot read another device.
Step 6: Show the Data
A dashboard signed in as an admin or as the device owner can listen for live updates with the Firebase JS SDK:
import { getDatabase, limitToLast, onValue, orderByChild, query, ref } from 'firebase/database';
const db = getDatabase();
onValue(ref(db, `devices/${deviceUid}/latest`), snap => {
const { t, h, ts } = snap.val() ?? {};
console.log(`${t} °C, ${h}% at ${new Date(ts).toLocaleTimeString()}`);
});
// Last 60 readings, using the ts index from the rules
const recent = query(ref(db, `devices/${deviceUid}/readings`), orderByChild('ts'), limitToLast(60));Production Checklist
- One Firebase user per device, so a compromised device can be disabled on its own.
- Never use database secrets or service-account keys on devices.
- Validate certificates; never call
setInsecure()outside a quick lab test. - Plan for certificate and firmware updates (over-the-air updates on the ESP32).
- Limit history growth: aggregate or delete old readings with a scheduled Cloud Function, and watch your database size and download costs.
- For fleets of thousands of devices or MQTT-based messaging, consider a dedicated IoT platform and use Firebase for the app side.
Conclusion
An ESP32 can talk to Firebase Realtime Database securely with nothing more than HTTPS: sign in as a per-device user, refresh the ID token, write readings with server timestamps, and let Security Rules enforce who writes what. Test the protocol and the rules against the emulators first, then flash the hardware.
FAQs
Is the FirebaseArduino library still maintained?
No. The FirebaseExtended/firebase-arduino repository was archived in March 2025. Use plain HTTPS with the REST APIs, as in this guide, or a maintained library such as FirebaseClient.
Should an IoT device use a database secret?
No. Database secrets are a legacy credential with full database access, so a leaked secret exposes everything. Give each device its own Firebase Authentication user and use its short-lived ID token, so Security Rules apply.
How does the device keep its token valid?
ID tokens expire after about an hour. The firmware stores the refresh token from sign-in and exchanges it for a new ID token at the securetoken endpoint a few minutes before expiry, signing in again if the refresh fails.
Why does my ESP32 fail the TLS handshake?
Common causes are an unset clock (sync with NTP first) and a missing or wrong root certificate. Trust a root that anchors Firebase’s certificate chains, and update it before it expires.
Does this work on an ESP8266?
The protocol is the same, but the ESP8266 uses a different TLS stack (BearSSL) and HTTP client API, and has much less memory. A maintained library such as FirebaseClient handles those differences.
How do I timestamp readings correctly?
Send {".sv": "timestamp"} as the value and let the server fill in its time. The rules in this guide require it, so devices with wrong clocks cannot write misleading timestamps.








