Skip to main content

Free 30-min security demo  — We'll scan your real code and show live findings, no commitment Book Now

Offensive360
Application Security

Entity Framework Core Security Best Practices (2026)

Entity Framework Core security guide: prevent SQL injection via FromSqlRaw, fix insecure model binding, protect connection strings, and integrate SAST scanning in your EF pipeline.

Offensive360 Security Research Team — min read
Entity Framework Core EF Core security SQL injection EF Core FromSqlRaw dotnet security .NET security C# security SAST .NET SAST ASP.NET Core security EF Core SQL injection dotnet ef code security

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 TABLE statements 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:

VulnerabilityPattern Detected
SQL injection via FromSqlRawUser input reaches FromSqlRaw(string) without parameterization
SQL injection via ExecuteSqlRawTainted string passed to ExecuteSqlRaw()
Second-order SQL injectionStored value from DB later used in raw SQL without parameterization
Mass assignmentEF entity used directly as model-bound API parameter
Hardcoded connection stringConnection string containing Password= in source code
Sensitive data loggingEnableSensitiveDataLogging() 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 all UPDATE/DELETE/INSERT with dynamic values
  • Dynamic ORDER BY columns 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.json committed 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 CA2100 as a build error
  • Full taint-analysis SAST scan running on pull requests and release builds
  • RunAnalyzersDuringBuild=false passed only to dotnet ef migration 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.

Offensive360 Security Research Team

Application Security Research

Find vulnerabilities before attackers do

Run Offensive360 SAST and DAST against your applications and get a full vulnerability report in minutes.