DEV Community

Zero Heartbeat
Zero Heartbeat

Posted on Originally published at delta1labs.com

Obfuscating an Entity Framework Core app without breaking your data layer

Entity Framework Core is a mapping layer, and the thing it maps from, by default, is your names. Call a class Invoice with a property Status and — with no configuration at all — EF decides there is a table Invoices with a column Status, because convention derives the schema from the CLR identifiers. That convention is wonderful for productivity and a landmine for obfuscation. The moment Nebula.NET renames Invoice to a and Status to b, the convention faithfully concludes your database has a table a with a column b, and nothing matches reality anymore.

The usual reaction is to exclude the whole data layer from obfuscation. That works and it is a bad trade: your entities and your DbContext are often the clearest description of your domain you ship, and you have just left them in plaintext to protect a database schema that was never the sensitive part. There is a precise fix instead. Find every place EF reads a CLR name at runtime, make the schema explicit so it stops deriving from those names, and then let Nebula rename the members. This post is that inventory and those patterns.

Where EF actually reads a name

Renaming is safe exactly where EF does not depend on the original identifier at runtime, and unsafe where it does. There are four places it does:

  1. Convention-based table and column names. With no explicit mapping, the table name is the DbSet property name (or the type name) and each column is the property name. Rename the member and the derived schema name moves with it.
  2. String-based member access. EF.Property<T>("Status"), entry.Property("Status"), and shadow-property lookups resolve a member by string. A string does not participate in renaming, so after obfuscation it names a member that no longer exists and throws.
  3. Migrations. A generated migration and the model snapshot contain the schema names as string literals. If those were produced from convention, they encode the original names; after a rename the running model derives different names and the two disagree.
  4. Anything reflecting over your model by name — a few serializers, auditing shims, or a provider feature that keys off navigation-property names.

LINQ, notably, is not on this list. Where(i => i.Status == open) compiles to an expression tree holding a MemberInfo, not the string "Status". When Nebula renames the property, that MemberInfo points at the renamed member, and EF translates it through your mapping. Consistent renaming — which Nebula guarantees across the whole assembly — keeps every compiled query intact. The breakage is always about names resolved from strings, never about typed member access.

Pin the schema, then obfuscate freely

The entire strategy is one idea: decouple the schema name from the CLR name. Once every table, column, key and index name is a string literal in your configuration, the CLR identifiers carry no schema meaning, and Nebula can rename them to a.b with no effect on the database.

protected override void OnModelCreating(ModelBuilder model)
{
    model.Entity<Invoice>(e =>
    {
        e.ToTable("Invoices");                 // table name is now a literal
        e.HasKey(x => x.Id);
        e.Property(x => x.Id).HasColumnName("Id");
        e.Property(x => x.Status).HasColumnName("Status");
        e.Property(x => x.Total).HasColumnName("Total").HasPrecision(18, 2);
        e.HasIndex(x => x.Status).HasDatabaseName("IX_Invoices_Status");

        // Navigation + FK column, pinned the same way.
        e.HasMany(x => x.Lines)
         .WithOne(l => l.Invoice)
         .HasForeignKey(l => l.InvoiceId)
         .HasConstraintName("FK_InvoiceLines_Invoices");
    });
}
Enter fullscreen mode Exit fullscreen mode

Those string literals — "Invoices", "Status", "IX_Invoices_Status" — are exactly the kind of thing Nebula's string encryption protects in the output, but they are never renamed, because they are data, not identifiers. The x => x.Status lambdas are typed member access, so they ride the rename automatically. The net result: Invoice.Status can become a.b in the shipped binary while the SQL EF emits still reads and writes the Status column.

Data annotations achieve the same decoupling if you prefer them on the type:

[Table("Invoices")]
public class Invoice
{
    [Column("Id")]     public int Id { get; set; }
    [Column("Status")] public InvoiceStatus Status { get; set; }
    [Column("Total")]  public decimal Total { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

Either way, the obfuscation rule in nebula.json is simply that your data layer is not special — it obfuscates with everything else:

{
  "preservePublicApi": true,
  "encryptStrings": true,
  "controlFlowObfuscation": true,
  "renaming": {
    "rules": [
      { "pattern": "MyApp.Data.*", "action": "rename" }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

No blanket exclusion for MyApp.Data.*. The entities get renamed; the schema is pinned in configuration; the two no longer touch.

Kill the string-based access

The patterns that genuinely break are the ones that resolve a member from a string, because a string never follows a rename. Find them and move to the typed form.

// ✗ Breaks: "Status" names a CLR member that obfuscation renamed to "b".
var q1 = db.Invoices.Where(i => EF.Property<InvoiceStatus>(i, "Status") == open);

// ✓ Typed member access — compiles to a MemberInfo that follows the rename.
var q2 = db.Invoices.Where(i => i.Status == open);
Enter fullscreen mode Exit fullscreen mode

Shadow properties are the one case where a string is legitimate — the member does not exist on the CLR type at all — so there is nothing to rename and nothing to break. Declare them explicitly and access them by their schema name, which you control:

model.Entity<Invoice>().Property<DateTime>("LastModifiedUtc");   // shadow, no CLR member
// ...
var ts = db.Entry(invoice).Property("LastModifiedUtc").CurrentValue;  // safe: not a CLR name
Enter fullscreen mode Exit fullscreen mode

Raw SQL is the other place to audit. A raw query must name the column (Status), never the CLR property — and since you pinned the column name to a literal, those line up permanently regardless of obfuscation:

// Names the column, which is fixed — safe under any rename.
var rows = db.Invoices.FromSqlInterpolated(
    $"SELECT * FROM Invoices WHERE Status = {(int)open}");
Enter fullscreen mode Exit fullscreen mode

Migrations: generate against the mapped model

Migrations encode schema names as literals, so they are safe if those literals are your pinned names. They become a hazard only when the snapshot was generated from convention and therefore captured CLR-derived names that later move. Two rules keep migrations clean:

  • Generate migrations from the non-obfuscated build. The design-time tooling reflects over your model to produce the migration and the snapshot; run it against the normal Debug assembly, never the obfuscated artifact. The migration you commit then contains your explicit schema literals.
  • Never put nameof(SomeProperty) in a hand-edited migration. nameof captures the CLR name at compile time, so after a rename it emits "b" where you meant the column "Status". Write the column name as a literal.
// In a migration — literal column names, matching your explicit mapping.
migrationBuilder.CreateIndex(
    name: "IX_Invoices_Status",
    table: "Invoices",
    column: "Status");          // literal, not nameof(Invoice.Status)
Enter fullscreen mode Exit fullscreen mode

Because the obfuscated binary and the migration both speak in the same fixed schema vocabulary, context.Database.Migrate() at startup behaves identically whether or not the assembly was obfuscated.

The rare surgical exclusion

Occasionally a specific feature still reflects over a CLR name at runtime — an auditing component that records the navigation-property name, or a provider extension that keys off it. When that happens, preserve that member, not the assembly. Nebula lets you opt a single member out with an attribute, which keeps the exclusion visible in the source where the dependency lives:

public class Invoice
{
    // A downstream component reflects over this navigation's name at runtime.
    [Obfuscation(Feature = "renaming", Exclude = true)]
    public List<InvoiceLine> Lines { get; set; } = new();
}
Enter fullscreen mode Exit fullscreen mode

That is the whole escape hatch — one property, documented by its own attribute — instead of a wildcard that spares your entire domain model. The discipline is worth it: the point of obfuscating a data-driven app is that the valuable logic is in the entities and the query layer, and a blanket exclusion protects none of it.

Putting it together

EF Core breaks under obfuscation only where it resolves a name from a string at runtime, and every one of those places has a decoupled alternative. Pin tables, columns, keys and indexes with explicit mapping so the schema stops deriving from CLR identifiers; replace EF.Property and nameof-in-migrations with typed or literal forms; generate migrations from the clean build. Do that and your MyApp.Data.* types obfuscate right alongside the rest of the app — renamed, string-encrypted, control-flow-hardened — while the database sees exactly the schema it always has. You protect the domain model that was worth protecting, and you give up nothing to the mapping layer.

Top comments (0)