Entity Framework Core (EF Core) is the default ORM for ASP.NET Core applications, and its LINQ query interface eliminates most traditional SQL injection risks — but not all of them. Several EF Core APIs bypass the ORM’s parameterization protections entirely, and the ecosystem around EF Core (model binding, migrations, connection string management) introduces additional attack surfaces that developers routinely overlook.
This guide covers every significant EF Core security concern in 2026: which APIs are safe, which are dangerous, how to harden your data access layer, and how to integrate static analysis into your EF Core development workflow.
The EF Core Security Model: What It Protects and What It Doesn’t
EF Core’s primary security benefit is automatic parameterization of LINQ queries. When you write:
var user = await context.Users
.Where(u => u.Username == username && u.IsActive)
.FirstOrDefaultAsync();
EF Core generates a parameterized SQL query internally:
SELECT TOP(1) * FROM [Users]
WHERE [Username] = @p0 AND [IsActive] = 1
The value of username is passed as a parameter — it never appears in the query string itself. An attacker cannot inject SQL through this path regardless of what the username variable contains.
This protection only applies to LINQ queries. The moment you use raw SQL APIs, you’re responsible for parameterization yourself.
SQL Injection Risks in Entity Framework Core
1. FromSqlRaw() with String Interpolation
The most common EF Core SQL injection vulnerability is using FromSqlRaw() with a C# string interpolation:
// VULNERABLE — string interpolation bypasses parameterization
public async Task<User?> GetUserByIdAsync(string userId)
{
return await context.Users
.FromSqlRaw($"SELECT * FROM Users WHERE Id = {userId}")
.FirstOrDefaultAsync();
}
// Attacker sends userId = "1; DROP TABLE Users; --"
// Generated SQL: SELECT * FROM Users WHERE Id = 1; DROP TABLE Users; --
The $ prefix tells C# to evaluate the interpolation before passing the string to EF Core. By the time EF receives it, the string already contains the attacker’s payload — there’s nothing for the ORM to parameterize.
Fix: Use FromSqlInterpolated() instead
// SECURE — FromSqlInterpolated safely parameterizes the interpolated values
public async Task<User?> GetUserByIdAsync(string userId)
{
return await context.Users
.FromSqlInterpolated($"SELECT * FROM Users WHERE Id = {userId}")
.FirstOrDefaultAsync();
}
FromSqlInterpolated() uses C#‘s FormattableString type to extract the interpolated values before they’re inserted into the SQL string and passes them as proper SQL parameters. The resulting query:
SELECT * FROM [Users] WHERE Id = @p0
-- @p0 = '1; DROP TABLE Users; --' (safely parameterized, no injection possible)
2. FromSqlRaw() with String Concatenation
The equivalent bug using + concatenation:
// VULNERABLE — same problem as string interpolation
public async Task<IList<Product>> SearchProductsAsync(string searchTerm)
{
string sql = "SELECT * FROM Products WHERE Name LIKE '%" + searchTerm + "%'";
return await context.Products.FromSqlRaw(sql).ToListAsync();
}
Fix: Use raw string with parameters
// SECURE — explicit parameter placeholder in the SQL string
public async Task<IList<Product>> SearchProductsAsync(string searchTerm)
{
return await context.Products
.FromSqlRaw("SELECT * FROM Products WHERE Name LIKE {0}", $"%{searchTerm}%")
.ToListAsync();
}
// Or better — use LINQ Contains which generates a parameterized LIKE automatically:
public async Task<IList<Product>> SearchProductsAsync(string searchTerm)
{
return await context.Products
.Where(p => p.Name.Contains(searchTerm))
.ToListAsync();
}
3. ExecuteSqlRaw() with Dynamic Input
The same injection vulnerability exists in ExecuteSqlRaw(), which is used for UPDATE, DELETE, and INSERT statements:
// VULNERABLE — DELETE with concatenated user input
public async Task DeleteUserAsync(string userId)
{
await context.Database
.ExecuteSqlRaw($"DELETE FROM Users WHERE Id = {userId}");
}
Fix:
// SECURE — use ExecuteSqlInterpolated
public async Task DeleteUserAsync(string userId)
{
await context.Database
.ExecuteSqlInterpolated($"DELETE FROM Users WHERE Id = {userId}");
}
// Or use the parameterized overload with explicit parameters
public async Task DeleteUserAsync(string userId)
{
await context.Database
.ExecuteSqlRaw("DELETE FROM Users WHERE Id = {0}", userId);
}
4. Dynamic OrderBy via String
A less-obvious injection vector: building dynamic ORDER BY clauses using user-supplied column names. LINQ’s OrderBy is type-safe when used with lambda expressions, but some developers switch to raw SQL for dynamic sorting:
// VULNERABLE — user-controlled column name in ORDER BY
public async Task<IList<User>> GetUsersSortedAsync(string sortColumn, string sortDirection)
{
// sortColumn and sortDirection come from user input (e.g., query parameters)
string sql = $"SELECT * FROM Users ORDER BY {sortColumn} {sortDirection}";
return await context.Users.FromSqlRaw(sql).ToListAsync();
}
// Attacker sends: sortColumn = "(SELECT TOP 1 Password FROM Users)"
Fix: Validate against an allowlist
// SECURE — validate column and direction against a strict allowlist
private static readonly HashSet<string> AllowedSortColumns = new(StringComparer.OrdinalIgnoreCase)
{
"Username", "Email", "CreatedAt", "LastLoginAt"
};
private static readonly HashSet<string> AllowedSortDirections = new(StringComparer.OrdinalIgnoreCase)
{
"ASC", "DESC"
};
public async Task<IList<User>> GetUsersSortedAsync(string sortColumn, string sortDirection)
{
if (!AllowedSortColumns.Contains(sortColumn))
sortColumn = "CreatedAt"; // Safe default
if (!AllowedSortDirections.Contains(sortDirection))
sortDirection = "ASC"; // Safe default
// Now safe to embed — values are from a controlled allowlist, not raw user input
string sql = $"SELECT * FROM Users ORDER BY [{sortColumn}] {sortDirection}";
return await context.Users.FromSqlRaw(sql).ToListAsync();
}
5. Second-Order SQL Injection via EF Core
Second-order SQL injection is particularly insidious: the attacker submits malicious input that is safely stored in the database (parameterized), but that stored value is later used unsafely in a raw SQL query.
// Step 1: Safe storage — username parameterized correctly
var user = new User { Username = username }; // username = "admin'--"
context.Users.Add(user);
await context.SaveChangesAsync(); // Safely stored: Username = "admin'--"
// Step 2: Later query — retrieves the username from DB and uses it unsafely
string storedUsername = await GetUsernameFromDbAsync(userId); // Returns "admin'--"
// VULNERABLE — treats database-sourced data as trusted
string sql = $"SELECT * FROM AuditLogs WHERE Username = '{storedUsername}'";
var logs = await context.AuditLogs.FromSqlRaw(sql).ToListAsync();
// Generated: SELECT * FROM AuditLogs WHERE Username = 'admin'--'
// The comment (--) truncates the rest of the query
The fix is the same: never interpolate any string into raw SQL, regardless of where that string came from. Database-sourced values are not “safe” — they may have been stored by an attacker.
// SECURE — parameterize even database-sourced values
var logs = await context.AuditLogs
.FromSqlInterpolated($"SELECT * FROM AuditLogs WHERE Username = {storedUsername}")
.ToListAsync();
Mass Assignment / Over-Posting Vulnerabilities
ASP.NET Core’s model binding can introduce a different class of vulnerability when EF Core entities are used directly as API model classes.
The Problem: Binding to EF Entities Directly
// DANGEROUS PATTERN — binding HTTP request body directly to an EF entity
[HttpPost]
public async Task<IActionResult> UpdateProfile([FromBody] User user)
{
context.Users.Update(user);
await context.SaveChangesAsync();
return Ok();
}
If the User entity has properties like IsAdmin, Role, or AccountBalance, an attacker can send a crafted JSON body:
{
"id": 42,
"username": "attacker",
"email": "[email protected]",
"isAdmin": true,
"role": "Administrator",
"accountBalance": 99999.99
}
EF Core will bind all these properties and update them in the database.
Fix: Use DTOs and Explicit Mapping
Never bind HTTP requests directly to EF entities. Use a DTO (Data Transfer Object) that only exposes the fields the user is allowed to change:
// Safe DTO — only includes user-editable fields
public class UpdateProfileDto
{
[Required, MaxLength(100)]
public string DisplayName { get; set; } = string.Empty;
[Required, EmailAddress, MaxLength(256)]
public string Email { get; set; } = string.Empty;
[MaxLength(500)]
public string? Bio { get; set; }
}
[HttpPost]
public async Task<IActionResult> UpdateProfile([FromBody] UpdateProfileDto dto, ClaimsPrincipal user)
{
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
var entity = await context.Users.FindAsync(userId);
if (entity is null) return NotFound();
// Explicit mapping — only the fields in the DTO are updated
entity.DisplayName = dto.DisplayName;
entity.Email = dto.Email;
entity.Bio = dto.Bio;
// entity.IsAdmin, entity.Role, entity.AccountBalance are never touched
await context.SaveChangesAsync();
return Ok();
}
Connection String Security
Never Hardcode Connection Strings
The most common credential exposure in .NET applications is a production database connection string committed to the source repository:
// VULNERABLE — appsettings.json committed to git with real credentials
{
"ConnectionStrings": {
"DefaultConnection": "Server=prod-sql.company.com;Database=AppDB;User=sa;Password=Prod@2024!"
}
}
Fix: Use environment variables, user secrets, or Azure Key Vault
// Development: use dotnet user-secrets
// dotnet user-secrets set "ConnectionStrings:DefaultConnection" "Server=localhost;..."
// Production: inject via environment variable
// ASPNETCORE_ConnectionStrings__DefaultConnection="Server=prod-db;..."
// Azure: use Azure Key Vault reference
// In appsettings.json:
{
"ConnectionStrings": {
"DefaultConnection": "" // Empty — overridden at runtime
}
}
// In Program.cs — pull from Key Vault at startup:
builder.Configuration.AddAzureKeyVault(
new Uri("https://your-vault.vault.azure.net/"),
new DefaultAzureCredential()
);
The general rule: appsettings.json should contain only non-sensitive configuration. Secrets go in:
dotnet user-secrets(development)- Environment variables (CI/CD, containers)
- Azure Key Vault / AWS Secrets Manager / HashiCorp Vault (production)
Use the Principle of Least Privilege for Database Accounts
The database account used by your application should have only the permissions it needs:
-- Create a restricted application user (SQL Server example)
CREATE LOGIN AppUser WITH PASSWORD = 'strong-generated-password';
CREATE USER AppUser FOR LOGIN AppUser;
-- Grant only the operations the app actually performs
GRANT SELECT, INSERT, UPDATE, DELETE ON SCHEMA::dbo TO AppUser;
-- Do NOT grant: DROP, CREATE TABLE, TRUNCATE, ALTER, EXECUTE (unless required)
-- Revoke dangerous permissions
REVOKE EXECUTE ON xp_cmdshell TO AppUser;
If an attacker achieves SQL injection despite your ORM, a least-privilege account limits the blast radius — they cannot drop tables, read from other schemas, or execute OS commands.
EF Core Migrations Security Considerations
Storing Migrations in Source Control
EF Core migration files contain the full DDL of your database schema evolution. This is intentional — migrations should be committed to source control. However, be careful about:
- Initial migration files that include
CREATE TABLEstatements with seed data containing test credentials - Data migration scripts that embed actual business data or PII
- Rollback procedures that might expose schema details exploitable in attacks
Running Migrations Securely in CI/CD
When running dotnet ef database update in a CI/CD pipeline, use a dedicated migration account with elevated permissions (ALTER TABLE, CREATE TABLE) that is separate from the application account:
# GitHub Actions — separate migration step with elevated permissions
- name: Run EF Migrations
env:
# Migration account has broader permissions than the runtime app account
ConnectionStrings__DefaultConnection: ${{ secrets.MIGRATION_DB_CONNECTION }}
run: |
dotnet ef database update \
--project src/DataAccess \
-- /p:RunAnalyzersDuringBuild=false
- name: Deploy Application
env:
# Runtime account has least-privilege permissions
ConnectionStrings__DefaultConnection: ${{ secrets.RUNTIME_DB_CONNECTION }}
run: |
# Deploy app with restricted database credentials
For the -- /p:RunAnalyzersDuringBuild=false flag: this disables Roslyn analyzers during the EF migration build step to prevent analyzer conflicts from blocking migrations. See the dotnet ef RunAnalyzersDuringBuild guide for the full explanation. Do not set this globally — only pass it to the dotnet ef command, not to your main security build.
Sensitive Data Handling in EF Core Models
Encrypting Sensitive Columns
For sensitive data stored in the database (PII, payment data, SSNs), consider column-level encryption:
// EF Core value converter for AES-256 encryption at the application layer
public class EncryptedStringConverter : ValueConverter<string, string>
{
public EncryptedStringConverter(IEncryptionService encryptor)
: base(
v => encryptor.Encrypt(v), // Store: encrypt before saving
v => encryptor.Decrypt(v)) // Load: decrypt when reading
{ }
}
// Register in OnModelCreating
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var encryptor = serviceProvider.GetRequiredService<IEncryptionService>();
var converter = new EncryptedStringConverter(encryptor);
modelBuilder.Entity<Patient>()
.Property(p => p.SocialSecurityNumber)
.HasConversion(converter);
modelBuilder.Entity<Patient>()
.Property(p => p.DateOfBirth)
.HasConversion(converter);
}
Logging and Sensitive Data
EF Core’s query logging can expose sensitive data. Ensure production logging is configured to avoid logging parameter values:
// Program.cs — disable parameter logging in production
builder.Services.AddDbContext<AppDbContext>(options =>
{
options.UseSqlServer(connectionString);
if (builder.Environment.IsDevelopment())
{
// Detailed logging with parameter values (development only)
options.EnableSensitiveDataLogging();
options.LogTo(Console.WriteLine, LogLevel.Information);
}
// In production: EnableSensitiveDataLogging() is NOT called
// Parameters are logged as @p0, @p1 (not their actual values)
});
Never call EnableSensitiveDataLogging() in production — it logs all SQL parameter values, which may include passwords, personal data, and security tokens.
SAST Integration for EF Core Projects
Static application security testing catches EF Core vulnerabilities during development, before they reach production.
What a SAST Tool Should Detect in EF Core Code
A capable .NET SAST tool with taint analysis should flag:
| Vulnerability | Pattern Detected |
|---|---|
SQL injection via FromSqlRaw | User input reaches FromSqlRaw(string) without parameterization |
SQL injection via ExecuteSqlRaw | Tainted string passed to ExecuteSqlRaw() |
| Second-order SQL injection | Stored value from DB later used in raw SQL without parameterization |
| Mass assignment | EF entity used directly as model-bound API parameter |
| Hardcoded connection string | Connection string containing Password= in source code |
| Sensitive data logging | EnableSensitiveDataLogging() called outside dev environment check |
Roslyn analyzers catch some of these (particularly CA2100 for obvious single-method SQL injection), but miss second-order injection, cross-method taint flows, and mass assignment patterns. A dedicated SAST platform like Offensive360 performs interprocedural taint analysis that follows data across method calls, class boundaries, and stored/retrieved database values.
Running SAST Alongside EF Core Migrations in CI/CD
The recommended CI/CD pattern for .NET teams using EF Core and SAST together:
name: EF Core + Security
jobs:
security-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.x'
- name: Build with Roslyn security analyzers
run: |
dotnet build --configuration Release \
/p:RunAnalyzersDuringBuild=true \
/p:AnalysisLevel=latest-recommended \
/warnaserror:CA2100,CA3001,CA3006,CA5350,CA5351
# Run Offensive360 SAST (or other SAST tool) for deep taint analysis
- name: SAST Scan
run: |
# Submit to Offensive360 for interprocedural taint analysis
curl -X POST https://api.offensive360.com/scan/code \
-H "X-API-Key: ${{ secrets.O360_API_KEY }}" \
--data-binary @./src
migrate:
runs-on: ubuntu-latest
needs: security-scan
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.x'
- name: Install EF CLI
run: dotnet tool install --global dotnet-ef
- name: Apply Migrations
# RunAnalyzersDuringBuild=false ONLY for the migration step
run: |
dotnet ef database update \
--project src/DataAccess \
-- /p:RunAnalyzersDuringBuild=false
env:
ConnectionStrings__DefaultConnection: ${{ secrets.DB_MIGRATION_CONNECTION }}
EF Core Security Checklist
Use this checklist when reviewing an ASP.NET Core application that uses Entity Framework Core:
SQL Injection Prevention:
- No
FromSqlRaw()calls with string interpolation ($"...") or concatenation (+) -
FromSqlInterpolated()used for all raw SQL with dynamic values -
ExecuteSqlInterpolated()used for allUPDATE/DELETE/INSERTwith dynamic values - Dynamic
ORDER BYcolumns validated against a strict allowlist before embedding in SQL - Database-sourced values treated as untrusted and parameterized in subsequent queries
Model Binding:
- No EF entities used directly as
[FromBody]or[FromQuery]model-bound parameters - DTOs with explicit property mapping used for all API endpoints
-
[BindNever]applied to sensitive entity properties where entities must be used as models
Connection String Security:
- No connection strings with passwords in
appsettings.jsoncommitted to source control - Production credentials in environment variables, Key Vault, or secrets management service
- Separate database accounts for migration operations vs. runtime application operations
- Application database account uses least-privilege permissions
Data Protection:
-
EnableSensitiveDataLogging()not called in production code paths - Column-level encryption applied to PII, payment data, and other sensitive fields
- Audit columns (
CreatedBy,ModifiedBy) populated server-side, not from user input
SAST Integration:
- Roslyn security analyzers enabled with
CA2100as a build error - Full taint-analysis SAST scan running on pull requests and release builds
-
RunAnalyzersDuringBuild=falsepassed only todotnet efmigration commands, not to the main security build
Summary
Entity Framework Core’s LINQ interface eliminates most SQL injection by default — but FromSqlRaw(), ExecuteSqlRaw(), and string-concatenated dynamic queries remain dangerous. Second-order injection, mass assignment via model binding, hardcoded connection strings, and overly verbose logging are the other significant attack surfaces in EF Core applications.
The remediation pattern is consistent: prefer LINQ over raw SQL, use FromSqlInterpolated() when raw SQL is necessary, use DTOs to prevent mass assignment, and keep credentials out of source code.
Pair Roslyn analyzers with a deep .NET SAST tool to catch both the obvious patterns and the complex interprocedural data flows that only taint analysis can detect.
Offensive360 SAST performs deep taint analysis on ASP.NET Core and EF Core applications, detecting FromSqlRaw injection, second-order SQL injection, mass assignment, and hardcoded credentials. Run a one-time scan for $500 or book a demo to see a live analysis of your .NET codebase.