Object-relational mappers make SQL injection rare in ordinary queries, which is exactly why the remaining injection points, in raw query escape hatches, dynamic identifiers and ORM internals, survive code review. Teams using Hibernate, Django, Sequelize, Prisma, Entity Framework or ActiveRecord often assume the ORM handles injection, and also let it decide which fields reach the database and which related objects reach the client. This guide explains how ORM injection and ORM data leaks happen, where they appear in each major framework, how to detect them and how to harden code. It is for developers, code reviewers and application security engineers; for the general theory of SQL injection see the SQL Injection Prevention Guide.
Key takeaways
- ORMs parameterise values, not structure. Column names, sort directions, table names and JSON keys that reach SQL as identifiers are not protected by binding, and must come from a fixed allow list.
- Every ORM has an unsafe door.
$queryRawUnsafeandPrisma.raw, Djangoextra()andRawSQL, Hibernate HQL built by concatenation and Sequelize literals all accept text as SQL. - Passing request data as keyword arguments is injection by another route. Django has issued several fixes for SQL injection reached through crafted dictionary keys expanded into
annotate(),values(),order_by()andQobjects. - The ORM can leak as well as inject. Serialising whole entities,
fields = '__all__'and lazy-loaded relations expose columns and related records the endpoint never meant to return. - Mass assignment is the write-side twin. Binding a request body straight to an entity lets callers set fields such as role or tenant ID.
How the attack works
An ORM builds SQL from method calls and sends user values as bound parameters, so User.objects.filter(email=value) or repository.findByEmail(value) is safe whatever value contains. Injection becomes possible when application code gives the ORM text that it will treat as SQL or as an identifier rather than as a value. MITRE CAPEC catalogues this separately from classic SQL injection as CAPEC-109 Object Relational Mapping Injection, alongside CAPEC-66 SQL Injection.
There are four mechanisms:
- String-built raw queries: the developer drops to raw SQL for performance or a feature the ORM lacks, then formats input into the string.
- String-built ORM query languages: HQL, JPQL and similar languages are parsed like SQL, so concatenating input into an HQL string is injectable even though no raw SQL appears. OWASP's Query Parameterization Cheat Sheet shows the safe form using named parameters and
setParameter(), or the Criteria API. - Dynamic identifiers: a sort column, filter field or report column comes from the request. Parameter markers cannot represent identifiers or keywords such as
ASCandDESC, as OWASP, Prisma and Sequelize all note, so developers interpolate them. - Request data as ORM structure: a request's JSON object or query string is expanded as keyword arguments or a filter object, so the attacker controls field names, lookups and connectors that the ORM turns into SQL fragments.
Leaks work differently. An endpoint returns an ORM entity and a serialiser walks every field and, with lazy loading, every relationship it touches, so the response includes password hashes, internal flags or another tenant's related records.
Where it shows up
| Framework | Safe default | Unsafe or risky API | Safe alternative |
|---|---|---|---|
| Django | QuerySet methods; parameters escaped by the driver | raw() or cursor.execute() with string formatting; extra(); RawSQL; **request.GET into filter(), annotate() or Q() | Pass params and leave %s placeholders unquoted; map allowed field names explicitly |
| Prisma | Model queries; $queryRaw tagged templates | $queryRawUnsafe, $executeRawUnsafe with concatenation; Prisma.raw() | Tagged templates or Prisma.sql with variables; Prisma.join for lists |
| Sequelize | Model finders; bind parameters | sequelize.query with template strings; literals built from input | bind ($1, $name) or replacements (?, :name) |
| Hibernate / JPA | Criteria API; named parameters | HQL or JPQL strings concatenated with input; native queries built by concatenation | setParameter(), Criteria API, allow-listed sort fields |
| Entity Framework Core | LINQ queries | Raw SQL APIs fed with a pre-built string | Interpolated or parameterised raw SQL APIs that bind values |
| ActiveRecord | Hash conditions | String conditions with interpolation in where or order | Placeholder conditions; allow-listed order columns |
Lessons from Django's security archive
Django's own security release history shows that even mature ORMs have injection paths through their structural APIs, nearly all reachable only when applications pass untrusted input as keyword argument names or aliases:
- CVE-2021-35042: unsanitised
QuerySet.order_by()input (1 July 2021). - CVE-2022-28346:
QuerySet.annotate(),aggregate()andextra()(11 April 2022). - CVE-2024-42005:
QuerySet.values()andvalues_list()(6 August 2024). - CVE-2025-59681:
annotate(),alias(),aggregate()andextra()on MySQL and MariaDB (1 October 2025). - CVE-2025-64459: the
_connectorkeyword argument inQuerySetandQobjects (5 November 2025). - CVE-2026-1287 and CVE-2026-1312: column aliases via control characters, and
order_bywithFilteredRelation(3 February 2026).
The practical lesson is not "Django is unsafe" but that Model.objects.filter(**request.GET.dict()) and similar patterns hand the attacker the ORM's structure. Patch promptly, and never expand request data into ORM keyword arguments.
Lazy loading and over-serialisation
- Entity serialisation: returning a JPA entity or Django model directly lets Jackson or a serialiser follow relationships; with lazy loading still active, each access triggers a query and pulls related rows into the response.
- Open session in view: Spring Boot enables
spring.jpa.open-in-viewby default and logs a start-up warning that database queries may be performed during view rendering, telling you to configure the property explicitly. With it on, lazy relations load during serialisation instead of failing. Set it tofalseand fetch what each endpoint needs. - Catch-all serialisers: Django REST framework's documentation strongly recommends setting
fieldsexplicitly rather than'__all__', so new model fields are not exposed when the model changes. - Query amplification: N+1 query patterns triggered by large lazy-loaded collections turn one request into thousands of queries, a resource consumption risk. See the OWASP API Top 10 API4 Unrestricted Resource Consumption Guide.
Impact
| Flaw | Typical impact | Made worse by |
|---|---|---|
| Raw query or HQL injection | Read or modify any data the database account can reach | Application database accounts with DDL or cross-schema rights |
| Identifier injection (sort, field names, JSON keys) | Blind data extraction through ordering or error messages | Verbose database errors returned to clients |
| Over-serialisation and lazy loading leaks | Exposure of hashes, internal fields and other tenants' related records | Shared tables without tenant filters |
| Mass assignment | Privilege escalation, tenant hopping, price or status tampering | Entities with role, tenant or approval fields |
| N+1 amplification | Database exhaustion and denial of service | Unpaginated collections |
The application's database account is a non-human identity, and its privileges set the blast radius of every ORM flaw. OWASP's SQL Injection Prevention Cheat Sheet recommends least-privilege accounts and views that expose only the columns an application needs.
Detection
- Code search and SAST: flag
$queryRawUnsafe,$executeRawUnsafe,Prisma.raw,.extra(,RawSQL(,.raw(with%or f-strings,createQuery(orcreateNativeQuery(with+,sequelize.query(with template literals, and**requestexpansions into querysets. MITRE D3FEND describes runtime analysis of query strings as Database Query String Analysis (D3-DQSA). - Serialiser review: search for
fields = '__all__', entities returned from controllers, andopen-in-viewleft at its default. - Database telemetry: alert on SQL syntax errors from application accounts, unusual
ORDER BYexpressions, queries touching system catalogues, and spikes in query count per request. - Testing: authorised DAST and API tests that fuzz sort, filter and field-selection parameters, not only values. In the McKinsey Lilli platform SQL injection, query values were parameterised but JSON field names were concatenated into SQL, and the researchers reported that standard scanners did not flag it.
Metrics:
- Count of unsafe raw query call sites, trending to zero or each with a recorded justification.
- Percentage of API responses built from explicit output DTOs or serialisers with explicit fields.
- Number of endpoints accepting dynamic sort or filter fields that use an allow list.
- Time to patch ORM security releases.
- Database SQL syntax errors from application accounts per week.
Prevention and hardening
- Prefer the ORM's query builder. Prisma's documentation calls model queries the ideal way to avoid SQL injection. Use raw SQL only where needed, and record why.
- Bind every value in raw SQL. Django
paramswith unquoted%s; Prisma tagged templates orPrisma.sql; Sequelizebindorreplacements; HibernatesetParameter(). Never format or concatenate, and never quote a placeholder. - Allow-list identifiers. Map user choices to fixed column names and directions in code, for example
{"newest": "-created_at", "price": "price"}, and reject anything else. - Never expand request data into ORM structure. Build filters field by field from validated input; do not pass
**request.GETor a client JSON object as filter or annotate arguments. - Use input and output DTOs. Bind requests to DTOs containing only editable fields, as OWASP's Mass Assignment Cheat Sheet recommends, and return output DTOs or explicit serialiser fields. ASVS 5.0 V15.3.3 expects allowed fields to be limited per controller and action. See the OWASP API Top 10 API3 Broken Object Property Level Authorization Guide.
- Control loading. Disable open session in view, fetch relations explicitly per endpoint, paginate collections and cap page sizes.
- Scope queries to the caller. Apply tenant and owner filters in a repository layer or with database row-level security, so a forgotten filter does not leak. ASVS V8.2.2 covers data-specific access.
- Least-privilege database identity. No DDL rights for the runtime account, separate accounts for migrations, and credentials from a secrets manager.
- Patch and test. Track ORM security releases and add regression tests that send hostile sort and field names.
Rollout: start with a code search across repositories, fix internet-facing endpoints first, then add SAST rules that block new unsafe calls in pull requests. Introduce output DTOs one endpoint at a time behind contract tests, so a field that clients rely on is not removed unnoticed; rolling back is a revert of the serialiser change.
Framework mapping
| Framework | Reference | Relevance |
|---|---|---|
| OWASP ASVS 5.0 | V1.2.4, V15.3.3, V8.2.2 | Parameterised queries including HQL; mass assignment; data-level access |
| MITRE CAPEC | CAPEC-66, CAPEC-109 | SQL injection; ORM injection |
| MITRE ATT&CK | T1190 Exploit Public-Facing Application | Exploiting injectable endpoints |
| MITRE D3FEND | D3-DQSA Database Query String Analysis | Detecting malicious query strings |
| NIST SP 800-53 Rev. 5 | SI-10, SA-11, AC-6, AU-12 | Input validation, developer testing, least privilege, audit records |
| NIST CSF 2.0 | PR.PS-06, DE.CM-09 | Secure development practice; monitoring software and data |
Lessons from incidents
The McKinsey Lilli incident is a clear example of the identifier gap: the developers had parameterised values, which is what most reviews check, while field names from the JSON body went straight into SQL. Errors that reflected those field names helped the attacker iterate, and the injection was chained with an IDOR flaw on unauthenticated endpoints. Three controls from this guide would each have broken the chain: an allow list for field names, generic error responses, and authentication with object-level authorisation on every endpoint.
Practitioner checklist
- Inventory every raw query, native query and string-built HQL or JPQL call, and justify or remove each.
- Bind all values; never format, concatenate or quote placeholders.
- Allow-list sort columns, directions, field selections and JSON keys that become identifiers.
- Ban expansion of request data into ORM keyword arguments or filter objects.
- Use input DTOs for writes and explicit output DTOs or serialiser fields for reads.
- Disable open session in view and paginate every collection endpoint.
- Enforce tenant and owner scoping centrally, ideally with row-level security as a backstop.
- Run the application under a least-privilege database account with no DDL rights.
- Patch ORM security releases quickly and add hostile-identifier regression tests.
Standards and references
- OWASP: SQL Injection Prevention Cheat Sheet
- OWASP: Query Parameterization Cheat Sheet
- OWASP: Mass Assignment Cheat Sheet
- MITRE CAPEC: Object Relational Mapping Injection
- Django: Security in Django
- Django: Archive of security issues
- Prisma: Raw queries
- Sequelize: Raw queries
Related NHI Mgmt Group resources: SQL Injection Prevention Guide · Django Security Guide · NoSQL Injection Prevention Guide · Java and Spring Security Guide