Note on language: I wrote this article in Japanese first and published it on Qiita. This English version was translated with AI assistance and then checked by me. The verification, the measurements, the SQL and the screenshots are all my own work. If a sentence reads oddly, the Japanese original is the source of truth.
1. Introduction
Oracle released Oracle Backend with Firebase APIs in May 2026. It is a Backend-as-a-Service style toolkit: you build a mobile or web app with a Firebase-shaped SDK (iOS, Android, JavaScript, or Flutter), and Oracle AI Database is the backend. It ships with Oracle REST Data Services (ORDS) 26.1.1 as a free feature, so any environment that already runs ORDS can use it. Oracle's announcement post has this line in it:
A VPD policy on a collection table fires on every Fusabase read and write
In other words, the Security Rules an app developer writes and the VPD policies and audit policies a DBA applies are meant to be two layers over the same data. App developers get something close to the Firebase experience, and the DBA's controls still apply to reads and writes that arrive through the SDK. I wanted to see that for myself, so I built an app first, then connected to the same database as a DBA and looked at what was there.
1.1. Conclusions up front
- Following the official documentation was not enough to reach the Console, and the app could not be called from a browser. Three settings that the documentation does not mention were required (section 7).
- A document written by the SDK lands in an ordinary table with a
JSONcolumn. You can read it withSELECT. - Both unified auditing and VPD apply to reads and writes that arrive through the SDK. But the database user in the audit trail is always the same single account, so the trail does not tell you which app user did it.
- A Security Rules denial is evaluated by a separate query that runs after the target rows have been read. It is a mechanism for not returning data, not a mechanism for not reading it.
1.2. What I wanted to verify
| # | Goal | Condition for "OK" |
|---|---|---|
| 1 | An app works with the Web SDK | Authentication, writes, and reads all work, and one user cannot reach another user's data |
| 2 | The storage behind a collection can be identified with SQL | The backing object can be named, and documents written by the SDK can be read with SELECT
|
| 3 | DBA-side controls apply to SDK traffic | Unified auditing records the operations, and applying VPD changes the rows the SDK gets back |
This is written for people who run Oracle Database and get asked by an app team whether they can use this toolkit.
2. Test environment
Everything here is free.
| Item | Detail |
|---|---|
| Database | Oracle AI Database 26ai Free 23.26.2.0.0 (gvenzl/oracle-free:23.26.2) |
| ORDS | 26.2.2.r2041619 (ords-latest.zip unpacked by hand, running on eclipse-temurin:21-jre-jammy) |
| JavaScript SDK |
fusabase 26.1.x (npm) |
| CLI |
fusabase-cli (installed from the GitHub source) |
| Host | Windows 11 Pro with Docker Desktop |
The app and the DBA reach the same tables through different paths.
Web app (fusabase SDK) ─┐
├─ REST ─> ORDS 26.2.2 ─ JDBC ─> Oracle AI Database 26ai Free
fusabase CLI ───────────┘ (Backend with (FREEPDB1)
Firebase APIs) ▲
DBA (SQL*Plus) ─────────────────────── SQL ──────────────────────────┘
One naming note, because you will see three names in this article. The product is Oracle Backend with Firebase APIs. fusabase is the name of the npm package and the CLI, and FUSABASE_USER is the schema I created for the project. The Console and the documentation URLs also use the shorter "Backend for Firebase" label in places, which is why it shows up in the screenshots.
3. Building the app (as a developer)
I picked a family shopping list as the subject. A parent creates a list, and nobody outside the family can read it. That is the whole thing.
3.1. What the SDK code looks like
You initialize the app and write data with setDoc. The function names and arguments are nearly identical to Firestore.
import { initializeApp } from 'fusabase/app';
import { getAuth, signInWithEmailAndPassword } from 'fusabase/auth';
import { getOracledb, doc, setDoc, getDocs } from 'fusabase/oracledb';
const app = initializeApp({
ords_host: 'http://localhost:8080/ords/fusabase_user/',
schema: 'fusabase_user',
app_id: 'xxxxxxxxxxxxxxxx',
app_type: 'web',
project_id: 'xxxxxxxxxxxxxxxx',
objs_type: 'none',
storage_bucket: 'none',
auth_type: 'base',
auth_id: 'xxxxxxxxxxxxxxxx',
});
const auth = getAuth(app);
const db = getOracledb(app);
// Sign in, then write one item into my own shopping list
const cred = await signInWithEmailAndPassword(auth, email, password);
await setDoc(doc(db, 'lists', cred.user.uid, 'items', 'item-milk'), {
title: 'Milk',
done: false,
createdAt: Date.now(),
});
The app_type key passed to initializeApp does not appear in the SDK README samples, but leaving it out produced an Invalid app type error.
Signing in shows your own list. The code up to this point is about 100 lines.
3.2. Security Rules
Access control is written in CEL (Common Expression Language). Mine is three lines that say "you can read and write only the list under your own ID".
match /lists/{familyId}/items/{itemId} {
allow read, write: if request.auth.uid == familyId;
}
You edit and publish rules from an editor in the Console, and there is a sandbox on the right side of the screen for trying them out.
3.3. What a denial looks like
I ran a check from Node.js: write three items as the parent, then try to read the parent's list as the child. The parent wrote all three and read the same three back.
setDoc ok: item-milk Milk
setDoc ok: item-eggs Eggs
setDoc ok: item-bread Bread
own list size (approx): 3
Reading the same list as the child gave this:
child signed in, uid= 58276915EF1B0DC2E063020013AC7F5F
getDocs result: size= 0 (no exception thrown)
getDoc result: exists()= false data= undefined
No exception is raised. You get an empty result. From the caller's side there is no way to tell "there are zero rows" apart from "you are not allowed". Since no permission error comes back, an app that wants to show a different message for the two cases needs something other than the row count to decide on. Section 4.4 shows what the database actually executed at that moment.
The data that was written is also visible in the Console's data browser.
4. Looking at it from the database (as a DBA)
From here on I connect with SQL*Plus, to see what shape the app's data takes inside Oracle.
4.1. What a collection really is
Listing the objects in the project schema showed the BAAS_ management tables, plus two tables with random-looking names. The mapping between them and the collection paths is in BAAS_COLLECTION_METADATA.
/lists -> DNHARCVFXZDP (type=doc)
/lists/_docId/items -> SSMCSCDZERKV (type=doc)
The collection path does not become the table name. Pulling the DDL showed an ordinary table.
CREATE TABLE "FUSABASE_USER"."SSMCSCDZERKV"
( "DOCUMENT" JSON,
"CREATED" TIMESTAMP (6),
"LAST_MODIFIED" TIMESTAMP (6),
"VERSION" NUMBER,
"PARENT_OID" VARCHAR2(4000),
"OID" VARCHAR2(50) DEFAULT SYS_GUID(),
CONSTRAINT "SSMCSCDZERKV$_PK" PRIMARY KEY ("OID")
) TABLESPACE "FUSABASE_TBS"
JSON ("DOCUMENT") STORE AS (TABLESPACE "FUSABASE_TBS" ...);
The document itself goes into the DOCUMENT column as JSON, alongside a modification timestamp, a version, a PARENT_OID that carries the parent-child relationship, and an OID primary key. It is a table with a JSON column, not a JSON Relational Duality View. I could not find a clear statement about the storage format in the developer's guide, so this is what the running system showed.
What I checked here is two collections created fresh from the SDK, both of the document type. The developer's guide also describes a model where an existing relational table is exposed as a collection, and it says a Duality View is generated per table in that case. So the same word "collection" can mean different things depending on how it was created.
4.2. Reading it with SQL
It is a normal table, so SELECT works.
SELECT oid, parent_oid, document, created FROM SSMCSCDZERKV ORDER BY created;
item-milk /58276915EF1A0DC2E063020013AC7F5F
{"title":"Milk","done":false,"createdAt":1785790839504} 03-AUG-26 09.00.00.551290 PM
item-eggs /58276915EF1A0DC2E063020013AC7F5F
{"title":"Eggs","done":false,"createdAt":1785790839564} 03-AUG-26 09.00.00.640038 PM
item-bread /58276915EF1A0DC2E063020013AC7F5F
{"title":"Bread","done":false,"createdAt":1785790839614} 03-AUG-26 09.00.00.719669 PM
The three documents the app wrote with setDoc came back as JSON in the DOCUMENT column, and PARENT_OID holds the ID of the signed-in user. Practically, this also means an existing analytics platform or BI tool can work with this data over SQL.
4.3. Does unified auditing record it?
I put a unified audit policy on the table and then read and wrote from the app.
CREATE AUDIT POLICY fusab_items_pol
ACTIONS SELECT, INSERT, UPDATE, DELETE ON FUSABASE_USER.SSMCSCDZERKV;
AUDIT POLICY fusab_items_pol;
Operations that came through the SDK were recorded in UNIFIED_AUDIT_TRAIL, down to the SQL text.
dbuser=FUSABASE_USER | client_id=[] | os_user=root | userhost=<ORDS container>
program=Oracle REST Data Services | action=SELECT
SQL: select json_object(* returning json) "osons" from "SSMCSCDZERKV" T
where T.PARENT_OID='/58276915EF1A0DC2E063020013AC7F5F' order by T.oid
dbuser=FUSABASE_USER | client_id=[] | program=Oracle REST Data Services | action=UPDATE
SQL: update "SSMCSCDZERKV" set version=version+1, document = :document,
last_modified = systimestamp at time zone 'UTC'
where oid = :oid and parent_oid = :parent_oid returning OID,version into :roid,:rversion
The two columns worth looking at are dbuser and client_id. Whether the parent or the child performed the operation, the database user recorded is always the single account FUSABASE_USER, and CLIENT_IDENTIFIER was empty. The only place the acting user appears is inside the SQL text. In this app I stored data under the path lists/{user id}/items, so PARENT_OID happens to hold the user ID. That is a consequence of the data model I chose, not the toolkit setting an end-user identifier on an audit column.
4.4. How a denied request looks in the audit trail
Section 3.3 showed that the child gets an empty result when reading the parent's list. Here is what ends up in the audit trail when that happens. With auditing still enabled, I ran only the child's denied read and dumped every audit record from that window.
The child's request did reach the database. The SELECT that ran is the same statement with the same bind values as when the parent reads their own list.
13:26:46.846 select json_object(* returning json) "osons" from "SSMCSCDZERKV" T
where T.PARENT_OID='/58276915EF1A0DC2E063020013AC7F5F' order by T.oid
13:26:46.854 select 1 from sys.dual where :1 = :2
binds: #1 58276915EF1B0DC2E063020013AC7F5F #2 58276915EF1A0DC2E063020013AC7F5F
The second statement is the authorization decision. It is a SELECT against sys.dual that does nothing but compare two values, and the binds are the ID of the signed-in user and the familyId taken from the path. For the child the two do not match (...EF1B0... against ...EF1A0...), and the SDK gets an empty result. When the parent performs the same operation, both binds hold the same ID and they match.
The CEL rule request.auth.uid == familyId has been turned into exactly that equality comparison in SQL. The developer's guide statement that CEL is translated into SQL and evaluated there is literal.
The part that matters to a DBA is the order. The SELECT that reads the data runs first, and the authorization check runs 8 milliseconds later. A request that ends in denial still causes the target rows to be read out of the database. In the audit trail, a denied access and a successful one are indistinguishable if you look only at the row that fetched data. To tell them apart you have to also look at the sys.dual comparison that follows it, and at its bind values.
4.5. Can VPD hide rows?
I also tried row-level access control, with a policy that excludes only rows whose title is Bread.
-- The policy function just returns a predicate that excludes rows where title = 'Bread'
BEGIN
DBMS_RLS.ADD_POLICY(
object_schema => 'FUSABASE_USER',
object_name => 'SSMCSCDZERKV',
policy_name => 'FUSAB_ITEMS_VPD',
function_schema => 'FUSABASE_USER',
policy_function => 'FUSAB_HIDE_BREAD',
statement_types => 'SELECT,UPDATE,DELETE');
END;
/
Reading the same list from the app now returned two items, with Bread gone. VPD does apply to reads that arrive through the SDK, as the announcement post says.
The write path is where it breaks. Calling setDoc against the hidden item-bread gave this:
ORA-20018: ORA-00001: unique constraint (FUSABASE_USER.SSMCSCDZERKV$_PK) violated
The audit trail shows that every setDoc runs select document from ... where oid = :1 and PARENT_OID = :2 to narrow to a single row before it issues the UPDATE. VPD hid the existing row from that SELECT, so it returned zero rows, an INSERT was issued instead, and that hit the primary key. setDoc on item-milk and item-eggs, which the VPD predicate does not match, both succeeded.
So the hidden row does not come back to the SDK on reads, but once the write path is included, hiding a row surfaces as an application error.
5. Discussion
5.1. The audit trail records the connected user, not the app user
A collection turns out to be an ordinary table, and both unified auditing and VPD apply to reads and writes that arrive through the SDK. The claim that a developer's Security Rules and a DBA's controls sit as two independent layers over the same data holds up.
What the audit trail cannot give you is identity. The database user is always the single account FUSABASE_USER, and CLIENT_IDENTIFIER was empty. You can tell that this app read the data; you cannot tell which user of the app read it from the audit columns alone. Getting that requires reading the user ID embedded in PARENT_OID, or parsing literals out of the SQL text.
This is the normal shape of a web app that shares a connection pool, and it is not a flaw specific to this toolkit. Still, once you are authenticating end users through a Firebase-style SDK, it is easy to assume their identifier travels down to the database. In this configuration it did not. If per-end-user traceability is a requirement, the design has to combine the audit trail with application-side logs.
5.2. Security Rules are evaluated in the database, but they do not narrow the fetch
The developer's guide explains that Security Rules are written in CEL and translated into SQL by a Java parser for evaluation.
In the audit trail, the translated SQL turned out to be a standalone statement: select 1 from sys.dual where :1 = :2. It is not folded into the predicate of the SELECT that fetches the data. It is decided by a separate query. The rule itself is also read out of BAASSYS.USER_BAAS_SECURITYRULES on every request.
The order is: the SELECT that reads the data first, the authorization decision second. Even for a request that ends in denial, the target rows are read out of the database, and that SELECT is identical to the one from a successful request. What happens is that the rows already read are not returned.
Two operational consequences follow. For reading the audit trail: looking at SELECT statements against a collection table tells you nothing about whether that access was allowed or denied. For design: Security Rules are a mechanism for not returning data to the app, not a mechanism for keeping the database from reading it. If you want to stop the read itself, you combine them with a database-side feature such as VPD.
I tried exactly one pattern, comparing request.auth.uid against a path variable. Rules with different content may well be translated differently.
5.3. Rows hidden by VPD do not come back on reads, but setDoc fails
Rows hidden by VPD did not come back to the SDK on reads, but setDoc against such a row failed with a primary key violation. The audit trail shows a select document from ... where oid = :1 and PARENT_OID = :2 narrowing to a single row immediately before each UPDATE. ORDS appears to use the result of that SELECT to choose between INSERT and UPDATE, and VPD hiding the existing row is what led it to choose INSERT. I did not capture the audit trail for the failing call itself, so that last step is inference.
This is a general property of VPD, and writes against hidden rows fail in other applications too. Even so, if you apply it thinking "the DBA can restrict rows without touching application code", it comes back as an application error. Read-only filtering and filtering that still has to pass writes need to be designed as separate cases.
6. Wrap-up
I built one app with Oracle Backend with Firebase APIs, then connected to the same database as a DBA and looked at what was inside.
- The SDK function names and arguments are close enough to Firebase that
setDocand a CEL Security Rule are all a minimal app needs. - Getting to the point where you can start, on the other hand, was ORDS administration work. Three settings that are not in the official procedure are required, and without them you reach neither the Console nor the app from a browser (section 7).
- A collection is an ordinary table with a
JSONcolumn, andSELECTreads it. Unified auditing and VPD both apply to SDK traffic, but the only user in the audit trail is the single database account used for the connection. - Security Rules are evaluated as an equality comparison in SQL translated from CEL. Because the decision runs after the data has been read, the audit trail cannot separate allowed from denied access by looking at the fetch alone.
What this verification produced is a list of things worth checking on the database side before handing the toolkit to an app team. The scope stops at authentication, data, and Security Rules; vector search and App Trust (which verifies that a request came from a genuine instance of your app) were out of scope.
If you want to try the toolkit yourself, Oracle publishes free LiveLabs workshops that build the same recipe app on three platforms: iOS, Android, and Web. The product page and the developer documentation are the other two starting points.
7. Appendix: where the setup got stuck
The rest of this is a record of what tripped me up while building the environment. It is here in case you are assembling the same stack. Skipping it does not affect anything above.
The official procedure is:
- Prepare the database (
COMPATIBLEat 23.9.0 or higher,MAX_STRING_SIZE=EXTENDED, configure a TDE wallet) - Enable the feature with
ords --config <config dir> fusabase install - Enable a project schema with
OBAAS_ADMIN.OBAAS_ENABLE_SCHEMA - Open the Console from the landing page at
/ords/_/landingand sign in
Steps 1 through 3 went as documented. TDE can be configured on the Free edition as well. Step 3's OBAAS_ENABLE_SCHEMA can fail with ORA-06598 when run as SYS, and the documentation's Troubleshooting section states that it should be run as a non-SYS user holding the DBA role. I followed that.
Depending on the container image, the prerequisites in step 1 may not be met. The gvenzl/oracle-free:23.26.2 image I used had COMPATIBLE at 23.6.0, short of the required 23.9.0. I raised it in the CDB and restarted.
-- Run in CDB$ROOT, then restart the instance
ALTER SYSTEM SET COMPATIBLE='23.9.0' SCOPE=SPFILE;
Changing MAX_STRING_SIZE means opening the PDB in UPGRADE mode and running utl32k.sql. For TDE you create a directory for the wallet, set WALLET_ROOT and TDE_CONFIGURATION, and create a master key. Both followed the official steps without trouble.
Note: A password-protected wallet stays closed after a database restart. Signing in from the app in that state fails with
ORA-28365: Wallet is not open, because app user information lives in an encrypted column. The symptom is odd: the Console signs in fine, and only app authentication fails. Either open the wallet after each restart, or use an auto-login wallet.
Step 4 was the problem.
7.1. The Console screen never renders
The Backend for Firebase tile does appear on the landing page. Clicking it leaves a loading indicator and nothing else.
The browser developer tools showed the JavaScript and CSS coming back 404. The 404 was on /ords/sql-developer/js/main-baas.js: the Console's static files are served from the SQL Developer Web path. And SQL Developer Web on that same landing page was showing "App Unavailable".
Enabling feature.sdw fixes it.
ords --config /opt/ords-config config set feature.sdw true
Turning it off returns 301, turning it on returns 200. I toggled it twice and got the same result both times. ORDS has to be restarted for the change to take effect.
Note: Once a 404 has been returned, the browser caches the permanent redirect (301) and keeps serving 404 even after the setting is corrected. Opening the page on
127.0.0.1instead oflocalhostcounts as a different origin and avoids it.
7.2. Credentials that work in SQL*Plus still return 401 at sign-in
The screen renders now, but signing in fails. The documentation says to sign in with the fusabase_user credentials, and those same credentials connect fine from SQL*Plus.
The schema-side prerequisites were satisfied too. The alias was registered as ENABLED in DBA_ORDS_SCHEMAS, DBA_PROXIES had the proxy grant from ORDS_PUBLIC_USER, and BAAS_ROLE was granted. POST /ords/fusabase_user/sign-in/check kept returning 401 anyway.
I raised ORDS logging to FINEST and traced it request by request, and also inspected the ORDS war. The cause was in how the authentication mode gets decided. When restEnabledSql.active is unset (it defaults to false), the component that validates the credentials entered in the form never starts at all. You get a 401 regardless of whether the password is correct.
ords --config /opt/ords-config config set restEnabledSql.active true
That one line made sign-in work. As a side effect, SQL Developer Web on the landing page stopped showing "App Unavailable".
One red herring: the logs at the time of the 401 also contain a JWT-related exception (MissingDependencyException ... security.jwt.profile.audience). It appears identically on successful sign-ins. It was not the cause.
7.3. Calling it from a browser requires registering the origin
There is one more setting, which I only needed after the app was written. Calling the SDK from Node.js worked, but the same code opened in a browser stops at authentication.
Access to fetch at 'http://localhost:8080/ords/fusabase_user/_/baas-services/idm/onprem/.../authenticate'
from origin 'http://localhost:8000' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
The origin serving the app is not authorized. The Console's project settings have an Authorized domains tab, and registering the origin there lets it through. The input has to include the scheme (http://localhost:8000); localhost:8000 was rejected.
ORDS has its own CORS setting (security.externalSessionTrustedOrigins), but setting it had no effect on the Backend with Firebase APIs endpoints. This is per-project configuration and has to be registered in the Console's project settings.
7.4. The three settings that were required
| Setting | Where | What happens without it | In the official docs |
|---|---|---|---|
feature.sdw=true |
ORDS config | The Console's static files 404 and the screen never renders | Not mentioned |
restEnabledSql.active=true |
ORDS config | Valid credentials return 401 at sign-in, every time | Not mentioned |
| Authorized domains | Console project settings | Calls from a browser are blocked by CORS | Not mentioned |
The first two are ORDS settings and have nothing to do with database configuration or schema privileges. For a toolkit introduced as being as easy as Firebase, getting to the starting line was ORDS administration work.
References
- Introducing Oracle Backend with Firebase APIs: Build Mobile and Web Apps on Oracle AI Database (Oracle Database Blog, 14 May 2026)
- Oracle Backend with Firebase APIs product page
- Oracle Backend for Firebase Developer's Guide (Get Started)
- Developer's Guide, 3 Installing and Configuring (3.2 Prerequisites, 3.6 Open the Console, 3.10 Troubleshooting)
- Developer's Guide, 2 Fundamentals (2.1.2 Oracle-Specific Terms, Java Parser (Security Rule); 2.3 Backend Overview)
- Simplified TDE configuration in Oracle Database 23ai Free (Oracle Developers Blog)
- LiveLabs workshops: Build a Recipe App (iOS) / (Android) / (Web)







Top comments (0)