A MERN dev’s journey from “never seen it” to shipping application-level Row Level Security.
I’ve been building urBackend since November 2025 — an open-source, MongoDB-native Backend-as-a-Service. The core bet: vendor lock-in is a solved problem, and we can solve it better than Firebase or Supabase. Connect your own MongoDB instance, get production-ready auth, storage, mails, webhooks, and database access. Your data never touches our servers. The source is fully open — unlike Supabase, whose SaaS layer is closed.
After shipping v0.3.0 in March, we hit a wall. A real one.
We needed RLS.
The Honest Starting Point I had heard the term Row Level Security exactly twice before this. Never used it. Never seen it implemented. Didn’t know what it actually did at a systems level.
So I did what any builder does: I read the docs. Supabase’s RLS docs. Postgres’s RLS docs. And honestly — it clicked. Postgres’s implementation is elegant. You define policies, the database engine enforces them at the executor level before returning rows. The “row” part is literal: the database physically filters out rows the user isn’t allowed to see, and the application never even knows they existed.
Then I hit the wall.
MongoDB doesn’t have this.
RLS in Postgres is a database-level feature. MongoDB has no equivalent. And I’m building a BaaS where the whole premise is MongoDB-native, MongoDB-first, MongoDB-everything.
So we had two options: skip RLS, or build it ourselves.
We built it.
Rethinking the Layer The key insight: if the database can’t enforce row-level access, push the constraint up one layer.
In Postgres, enforcement happens at query execution inside the engine. We’d do the same thing — but at the application layer, sitting between the client’s request and the DB driver. The principle doesn’t change. The layer does.
This is what we called application-level RLS over MongoDB — a middleware-driven query modification pattern.
The mental model is simple:
Client Request -> [RLS Middleware] -> Modified Query -> MongoDB -> Response The middleware is not optional routing logic. It is the security boundary.
Implementation: What Actually Runs Before RLS could work, we needed a concept of “who is making this request.” In v0.3.0, we had already shipped Publishable Keys (PK) — keys meant for frontend use with limited scope. The scoping was always planned; RLS was the reason.
The enforcement works through two middleware functions:
authorizeReadOps — protects GET requests For collections with RLS enabled, every read request gets a filter injected into the query before it touches MongoDB:
// What the client sends: GET /api/data/notes // What actually hits MongoDB: db.notes.find({ ...originalFilter, ownerId: req.user.userId }) The client never has to think about this. The middleware intercepts, reads the JWT from the Publishable Key auth, extracts the userId, and injects it as a mandatory filter condition. If the token is invalid — request dies. If the token is missing — request dies.
async (req, res, next) => { try { if (req.keyRole === 'secret') { req.rlsFilter = {}; return next(); }
const { collectionName } = req.params;
const project = req.project;
const collectionConfig = project.collections.find(c => c.name === collectionName);
if (!collectionConfig) {
return next(new AppError(404, 'Collection not found'));
}
const rls = collectionConfig.rls || {};
if (!rls.enabled) {
req.rlsFilter = {};
return next();
}
const modeRaw = rls.mode || 'public-read';
const mode = modeRaw === 'owner-write-only' ? 'public-read' : modeRaw;
if (mode === 'private') {
if (!req.authUser?.userId) {
return next(new AppError(401, 'Provide a valid user Bearer token for private reads.', 'Authentication required'));
}
const ownerField = rls.ownerField || 'userId';
req.rlsFilter = { [ownerField]: req.authUser.userId };
return next();
}
if (mode === 'public-read') {
req.rlsFilter = {};
return next();
}
return next(new AppError(403, 'Unsupported RLS mode'));
} catch (err) {
console.error('[authorizeReadOperation] Unexpected error:', err);
return next(new AppError(500, 'Internal Server Error'));
}};
authorizeWriteOps — protects POST, PUT, PATCH, DELETE This one handles three cases:
Become a Medium member
POST without an owner field The middleware auto-injects the userId as the owner. The document gets created with ownership baked in.
POST with an owner field that doesn’t match the token Rejected. You can’t create a document owned by someone else.
PUT/PATCH that tries to modify the owner field Rejected. Ownership transfer is blocked at the middleware layer entirely.
Both middlewares check the key type first — PK triggers RLS, Secret Key bypasses it for admin access. This is the admin escape hatch that every BaaS needs.
async (req, res, next) => { try { if (req.keyRole === 'secret') { return next(); }
const { collectionName, id } = req.params;
const project = req.project;
const collectionConfig = project.collections.find(c => c.name === collectionName);
if (!collectionConfig) {
return next(new AppError(404, 'The requested collection does not exist.', 'Collection not found'));
}
const rls = collectionConfig.rls || {};
if (!rls.enabled) {
return next(new AppError(403, 'Enable RLS for this collection to allow publishable-key writes.', 'Write blocked for publishable key'));
}
if (rls.requireAuthForWrite && !req.authUser?.userId) {
return next(new AppError(401, 'Provide a valid user Bearer token for write operations.', 'Authentication required'));
}
const modeRaw = rls.mode || 'public-read';
const allowedModes = new Set(['public-read', 'private', 'owner-write-only']);
if (!allowedModes.has(modeRaw)) {
return next(new AppError(403, 'The collection RLS mode is invalid.', 'Unsupported RLS mode'));
}
const ownerField = rls.ownerField || 'userId';
if (!req.authUser?.userId) {
return next(new AppError(401, 'Provide a valid user Bearer token for write operations.', 'Authentication required'));
}
const authUserId = String(req.authUser.userId);
const method = String(req.method || '').toUpperCase();
if (method === 'POST') {
if (ownerField === '_id') {
return next(new AppError(403, "RLS ownerField '_id' is not valid for insert ownership checks.", 'Insert denied'));
}
const bodyItems = Array.isArray(req.body) ? req.body : [req.body];
if (bodyItems.length === 0) {
return next(new AppError(400, 'Request body cannot be an empty array.', 'Invalid request body'));
}
for (let i = 0; i < bodyItems.length; i++) { const item = bodyItems\[i\];
if (!item || typeof item !== 'object' || Array.isArray(item)) {
return next(new AppError(400, `Item at index ${i} must be a valid object`, 'Invalid request body'));
}
const incomingOwner = item?.[ownerField];
if (incomingOwner === undefined || incomingOwner === null || incomingOwner === '') {
item[ownerField] = authUserId;
continue;
}
if (String(incomingOwner) !== authUserId) {
return next(new AppError(403, `Item at index ${i} must have ${ownerField} equal to your user id`, 'RLS owner mismatch'));
}
}
return next();
}
if (method === 'PUT' || method === 'PATCH' || method === 'DELETE') {
if (!id || !mongoose.Types.ObjectId.isValid(id)) {
return next(new AppError(400, 'The provided document ID is not valid.', 'Invalid ID format'));
}
req.rlsFilter = { [ownerField]: authUserId };
if (method === 'PUT' || method === 'PATCH') {
if (
req.body &&
Object.prototype.hasOwnProperty.call(req.body, ownerField) &&
String(req.body[ownerField]) !== authUserId
) {
return next(new AppError(403, `${ownerField} cannot be changed under RLS.`, 'Owner field immutable'));
}
}
return next();
}
return next();
} catch (err) {
console.error('[authorizeWriteOperation] Unexpected error:', err);
return next(new AppError(500, 'Internal Server Error'));
}};
Two Modes, Because One Isn’t Enough After shipping the initial version in v0.4.0, we realized RLS isn’t one-size-fits-all. Different products have different access patterns:
private-read-private-write Only the owner can read or write their own documents. Personal data, user settings, private notes. Classic security model.
public-read-private-write Anyone can read. Only the owner can write. Social apps, public posts with protected editing. This one's subtle — reads don't require auth, but writes are still fully enforced.
Both modes shipped in v0.7.0.
What the Config Looks Like RLS is configured at the project-collection level. Each collection stores an RLS config object in the DB:
{ "collectionName":
"notes",
"rls": { "enabled": true,
"mode": "private-read-private-write",
"ownerField": "userId" }
}
This also means toggles are dynamic — you can enable/disable RLS on a collection without redeploying anything. The middleware reads the config at request time.
What I’d Tell Myself Before Starting The hard part wasn’t the code. The middleware injection took maybe two days. The hard part was the design — understanding what RLS is actually doing at a semantic level, then figuring out how to replicate those semantics one layer up.
The thing Postgres RLS gives you for free: the enforcement is invisible to the application. You can’t forget to add a WHERE clause — the engine adds it. Replicating that guarantee at the application layer means your middleware has to be mandatory, not optional. There’s no “sometimes we check RLS” — every request touching a protected collection goes through those two functions, no exceptions.
Second thing: ownerField design matters more than the enforcement logic. We chose userId as the default, but making it configurable opened up use cases we didn't anticipate — team-owned resources, org-level access, etc. Build for the field to be flexible from day one.
Third: test with a bad actor, not a good user. The test case that matters isn’t “does Alice see her own notes.” It’s “can Alice craft a request to see Bob’s notes.” Write those tests explicitly.
Why This Gap Exists (and Why It Matters) MongoDB’s document model and its lack of native RLS creates a real gap for teams building multi-tenant applications. Most solutions either:
Implement access control entirely in application code (fragile, easy to miss) Use a separate auth layer that doesn’t know about data ownership Switch to Postgres just for RLS urBackend’s approach: make application-level RLS a first-class, infrastructure-level feature. You configure it once, and the BaaS layer enforces it everywhere the SDK touches. Even if your application code has a bug, the middleware holds.
The whole thing — from “I’ve heard the term RLS” to a shipped, two-mode, configurable implementation — took about three weeks. MongoDB might not support it at the DB level. But the layer above it can.
urBackend is open source under AGPL-3.0 license. If you’re building on MongoDB and dealing with multi-tenant access control, take a look: github.com/geturbackend












