DEV Community

praveenlavu
praveenlavu

Posted on • Originally published at praveenlavu.com

Dynamic SOQL Without Getting Burned

Dynamic SOQL Without Getting Burned

There is a particular kind of dread that sets in at two in the morning when you realize the query you shipped last week can be turned against you.

I was staring at a security review report, coffee going cold, reading a finding that should not have surprised me but did. The feature was a configurable search: administrators could pick which fields appeared in results, users could filter on any of them. Flexible, powerful, genuinely useful. The implementation built the query string at runtime from user-supplied field names. The finding said, in flat auditor language, that an attacker with access to the search interface could manipulate that field name to extract data from objects they were never supposed to see. The app had been in production for months.

This is the SOQL injection problem, and it is not exotic. It is one of the most common failures I see in Salesforce ISV security reviews, precisely because the features that invite it are also the features customers love most. Configurable dashboards. Flexible report builders. User-driven filter panels. Every one of them requires building queries from runtime input, and every one of them is a potential foot-gun if you have not thought carefully about the layers between what a user supplies and what hits the database.

I want to walk through how I think about this now, after enough scars to have developed an opinion.

Why Dynamic SOQL Exists, and Why It Bites

Static queries are safe by design. When you write a query with fixed object names, fixed field names, and only the filter values changing, the platform can reason about what you are asking for. The query shape is known at compile time. The only variable is data, not structure.

The moment a customer says "I want to search across whichever fields matter to me," you lose that static shape. The object, the fields, the filter logic, or all three might come from configuration or from direct user input. You are building a string and handing it to an interpreter, which is exactly the category of problem that has caused security incidents across every platform and language for decades.

On Salesforce, the blast radius is real. The SOQL engine runs inside the platform's security model, but that model does not protect you from a query that is structurally manipulated before it reaches the engine. If an attacker can inject their own field names or object references into a query string, they can potentially read fields across relationships the original query was never meant to traverse. In a multi-tenant ISV package, where customer data lives in the same org alongside your package's logic, the stakes are higher.

The good news is that there are three layers of defense that together close almost every vector I have seen. They compose, which means each one limits what can go wrong even if another layer is imperfectly applied.

The First Layer: Escaping Filter Values

The most well-known defense is escaping user-supplied values before they are embedded in a query string. The platform provides a method for this, and it does exactly what the name implies: it handles single-quote characters in a way that prevents them from being used to break out of a string literal inside the query.

This is necessary, but it is the weakest of the three layers because it only protects filter values. It does nothing about structural components of the query: the object name, the field names, the sort order, the limit logic. If any of those components come from user input and are not separately validated, escaping filter values leaves the door open.

I have seen implementations that escape diligently and still end up with injection vulnerabilities because someone assumed that the only dangerous input was in the WHERE clause values. The attacker does not care about the WHERE clause if they can control the SELECT list.

Think of escaping as the seat belt. You absolutely wear it. But you also want airbags and crumple zones.

The Second Layer: Bind Variables

Bind variables change the relationship between your code and the query engine fundamentally. Instead of building a string that includes both structure and values, you build the structural parts as a string and pass the value parts separately, in a way that the engine treats them as data rather than as query syntax.

When you use a bind variable, you are essentially saying: this thing I am passing you is a value, not syntax. The engine never interprets it as query language. It cannot be used to append additional query clauses, to comment out existing ones, or to reference different fields. It is data, full stop.

Bind variables are available for filter values in SOQL, and they are the correct approach for anything the user supplies as a search term, a record ID, a date range, or any other filtering input. The moment I understood this distinction, a lot of things that felt fragile started feeling solid.

The catch is that bind variables do not apply to structural query components. You cannot use a bind variable as a field name. You cannot use one as an object name. The query engine needs those parts as literal strings to construct the query, which means they are in a different threat category entirely.

The Third Layer: Schema-Based Allowlisting

This is where things get interesting, and where I spent the most time thinking before it clicked.

For structural query components, specifically the object names and field names that come from configuration or user choice, the defense is not about escaping or parameterizing. It is about validation: before you let a string become part of your query structure, you verify that it is a real, expected, accessible schema element.

The platform's schema describe capabilities give you everything you need for this. You can introspect the metadata of any object your code has access to, retrieve the set of fields that actually exist on it, check their accessibility and type, and make decisions based on real, platform-sourced information rather than a list you maintained yourself.

The pattern works like this at a conceptual level: when your feature needs to accept a field name as input, whether from an administrator's configuration or from a user's filter selection, you do not trust that string directly. You ask the platform what fields exist on the relevant object. You check whether the proposed field name is in that set. You also check whether the field type is appropriate for what you are doing. Only after those checks pass do you allow the field name into the query.

This is powerful for two reasons. First, it is automatically current. If your schema changes, the allowlist changes with it without you doing anything. Second, it is grounded in truth that the platform itself controls. An attacker cannot fake a valid schema describe response. Either the field exists and is accessible, or it does not.

I remember the moment this approach crystallized for me. I was trying to think about how an attacker would defeat field-name validation, and I kept reaching for the same answer: they would supply a field name that was not real, or one that accessed a relationship in an unexpected way, or one with special characters. Every single one of those is caught by asking the platform whether the field actually exists on the target object. The attacker cannot create valid schema entries. The platform controls that namespace entirely.

Object-name allowlisting follows the same pattern. If your feature lets administrators pick which objects a search applies to, you validate each object name against the platform's describe before it enters a query. You can also check object accessibility and whether your package has the right permissions to query it. The configuration-time check and the runtime check both use the same source of truth.

The Layers Working Together

Here is why composition matters. Suppose you have a feature that lets users filter on any field in a list they configure, and you apply all three layers.

Filter values go through escaping and bind variables. Even if a user tries to inject SOQL syntax into a search term, it never becomes query syntax.

Field names from the configuration go through schema validation before the query is built. Even if an attacker somehow manipulates the configuration, a field name that does not exist or that fails accessibility checks never reaches the query.

The query is still built as a string because SOQL requires structural elements to be literal, but the inputs to that string have all been filtered through one or more validation layers appropriate to their type.

What remains? The template around those validated inputs, the structural logic you write yourself, which is static and does not vary with user input. That part cannot be injected.

I ran this through a few attack scenarios mentally, and then more formally during a security review. The intersection of what an attacker controls and what reaches the query engine without validation is, ideally, empty.

What This Looks Like in Security Review

When I review a Salesforce application now, the questions I ask about dynamic SOQL are fairly specific.

Does any user-supplied input enter a query string directly, without escaping or bind variables? That is the first cut.

Does any query structural element, object names, field names, clause components, come from configuration or user input? If so, what validates it?

Is the validation based on a maintained list in the code, or is it grounded in real-time schema introspection? The former drifts and can be wrong. The latter is authoritative.

Can the feature's configuration be modified by low-privilege users, and if so, does the runtime validation still apply? This is the multi-tenant question. In a managed package, you often cannot trust that configuration has not been tampered with at runtime, so runtime validation using describe is more important than configuration-time validation.

Applications that fail these questions fail the security review. Not as a technicality, but because the risk is concrete and the fix is well-understood. The platform gives you the tools. The question is whether you reached for them.

The Principle

Every security control I care about in software follows the same basic shape: stop trusting things you do not have to trust, use authoritative sources rather than self-maintained lists, and separate the structural parts of a system from the data parts as clearly as possible.

Dynamic SOQL is a compressed case study in all three. You cannot avoid building queries at runtime if you want configurable features, but you absolutely can avoid trusting runtime-supplied strings to construct query structure. You can separate filter values from field names and treat them differently. You can ask the platform what is real instead of guessing.

The dopamine hit when a security review comes back clean on a feature that used to fail is real. But it is smaller than the relief you feel knowing that you actually understand why it is safe, not just that it passed this time.

If you are building configurable search on Salesforce, or any feature that assembles queries from pieces that users or administrators can influence, these three layers are the minimum bar. They are not expensive to implement. They are well worth the two-in-the-morning peace of mind.

Top comments (0)