You are three weeks from closing a six-figure deal. The customer's security team sends a vendor assessment form. Question 4: "Do you have a SOC 2 Type 2 report?"
You don't.
The deal goes on hold. Six months later, it dies.
This is happening more and more. SOC 2 Type 2 is no longer just a nice-to-have for companies selling to US enterprise — it is a procurement gate. And for .NET teams, the path from "we should probably do this" to "we have the controls in place" is less obvious than it should be.
This article maps the five SOC 2 Trust Service Criteria (TSC) to concrete .NET controls, and shows how Granit — an open-source modular .NET 10 framework — covers most of the technical side out of the box.
SOC 2 in one paragraph
SOC 2 is an AICPA standard. It comes in two flavors:
- Type 1: your controls exist at a point in time. Cheap to get. Low customer value.
- Type 2: your controls operated effectively over a 6–12 month observation window. Expensive. What enterprise customers actually want.
The audit covers up to five Trust Service Criteria. Only Security (CC) is mandatory. Most enterprise customers also require Availability and Confidentiality.
The key thing to internalize: the auditor does not certify your code. They certify that your controls operated and that your team followed procedures. Granit gives you the technical controls. The processes, runbooks, and evidence collection are still on you.
CC6 — Logical access controls
This is the biggest control family. It covers authentication, authorization, and how you prevent unauthorized access.
CC6.1 — Strong authentication
Granit's BFF module keeps tokens off the browser entirely. The access_token and refresh_token live server-side in an encrypted HttpOnly, SameSite=Strict session cookie. No XSS attack — including a compromised npm dependency — can steal a token the browser never had.
// Program.cs
builder.AddGranit(granit => granit
.AddModule<GranitBffModule>()
.AddModule<GranitBffYarpModule>());
For machine-to-machine flows, Granit.Authentication.DPoP implements RFC 9449 proof-of-possession tokens. Even if an attacker intercepts an access token, they cannot replay it without the client's private key.
The full FAPI 2.0 security profile — PKCE (RFC 7636) + PAR (RFC 9126) + DPoP (RFC 9449) + private_key_jwt (RFC 7523) — is available via Granit.OpenIddict.
CC6.3 — Authorization and least-privilege
Granit.Authorization stores permissions in the database and evaluates them at runtime. No recompile cycle when your access model changes. Permissions follow a strict [Group].[Resource].[Action] format enforced by architecture tests.
// Endpoint declaration
group.MapDelete("/{id:guid}", DeleteAsync)
.RequirePermission(InvoicePermissions.Invoices.Manage);
Per-tenant permission policies are built-in via Granit.MultiTenancy — the same endpoint can enforce different access rules for different customer organizations.
CC6.7 — Encryption at rest
Granit.Encryption provides IStringEncryptionService for field-level encryption. In development, it falls back to AES-256-CBC automatically. In production, Granit.Vault.HashiCorp delegates to HashiCorp Vault Transit — the key never leaves Vault:
public class PatientService(IStringEncryptionService encryption)
{
public async Task<Patient> CreateAsync(
string name,
string nationalId,
CancellationToken ct)
{
// nationalId is encrypted before it touches the database
string encrypted = await encryption.EncryptAsync(nationalId, ct)
.ConfigureAwait(false);
return Patient.Create(name, encrypted);
}
}
The Vault module disables itself in Development — no local Vault instance needed for day-to-day development.
CC6.8 — Malware and supply chain
See the BFF section above. Tokens never in localStorage. Supply chain XSS attacks are neutralized by design, not by CSP headers.
CC7 — System operations and monitoring
CC7.1 — Anomaly detection
Granit.Observability wires Serilog + OpenTelemetry in one module registration:
builder.AddGranit(granit => granit
.AddModule<GranitObservabilityModule>());
Every module emits:
-
Structured logs via
[LoggerMessage]source generation — no string interpolation, no accidental PII in log output -
Metrics via
IMeterFactory— standardized naming (granit.authorization.permission.check,granit.persistence.query.duration) -
Distributed traces via
ActivitySource— one per module, correlated with logs viatrace_id
The Grafana LGTM stack (Loki + Grafana + Tempo + Mimir) ships as a Docker Compose overlay. A trace_id on every request means you can reconstruct the exact sequence of events for any incident — which is what the auditor wants to see.
CC7.2 — Audit trail
This is the one control teams most often try to implement manually and get wrong. With Granit, it is automatic:
// Inheriting AuditedEntity gives you these four fields on every write:
// CreatedAt → IClock.Now (UTC, always)
// CreatedBy → ICurrentUserService.UserId (or "system" for jobs)
// ModifiedAt → IClock.Now
// ModifiedBy → ICurrentUserService.UserId
public sealed class Invoice : FullAuditedAggregateRoot<Guid>
{
public decimal Amount { get; private set; }
public static Invoice Create(decimal amount) =>
new() { Amount = amount };
// Audit fields set by interceptor inside SaveChangesAsync — not by application code
}
AuditedEntityInterceptor runs inside the same database transaction as the business write. You cannot have a committed write without an audit record. You cannot accidentally skip it.
For business-level events ("Invoice approved by Marie"), the Timeline module adds a human-readable activity log with actor, timestamp, and Markdown body — independent of the field-level audit trail.
Confidentiality TSC — crypto-shredding
Here is a problem that comes up in every SOC 2 + GDPR scenario:
- GDPR Art. 17 says you must be able to erase a user's data.
- SOC 2 CC7.2 says your audit trail must be immutable.
These appear to contradict. If you delete rows to satisfy GDPR, your audit trail references records that no longer exist. If you keep the rows, you're not actually erasing the data.
Granit.Privacy resolves this with crypto-shredding:
- Each tenant's sensitive fields are encrypted with a tenant-specific key stored in Vault.
- On erasure request, the key is destroyed in Vault.
- All encrypted fields become permanently unreadable — mathematically erased.
- The audit trail rows stay intact. The data they reference is gone.
No rows deleted. Full erasure. Intact audit trail. Compliant with both.
Availability TSC
Granit.RateLimiting protects against abusive traffic patterns. Standard 429 Too Many Requests with Retry-After header — easily validated in load tests for the auditor's evidence package.
Granit.MultiTenancy supports three isolation strategies. The DatabasePerTenant strategy means one tenant's database issue cannot cascade to others — a common availability concern for SaaS audits.
| Strategy | Risk containment |
|---|---|
SharedDatabase |
Logical (query filter) |
SchemaPerTenant |
Schema-level separation |
DatabasePerTenant |
Full physical isolation |
Privacy TSC
-
[LoggerMessage]source generation enforces that no PII can slip into log strings at compile time. The Roslyn analyzer flags violations before they reach CI. -
IProcessingRestrictableadds a global EF Core query filter for processing restriction (GDPR Art. 18) — applied byApplyGranitConventions, never forgettable. -
IPersonalDataProvideraggregates personal data exports across all modules for data portability requests (GDPR Art. 15 / SOC 2 P4.1).
What you still need to do
Granit handles the technical controls. The audit also requires:
- Incident response runbook — a documented procedure, not just Grafana dashboards
- Access review cadence — quarterly reviews of who has production access, with evidence
- Change management — pull request policies, deployment approval gates
- Employee security training — awareness + phishing simulation records
- Annual penetration test — with findings and remediation tracking
- Vendor due diligence — security assessments for your own third-party integrations
The observation window starts when you engage your auditor. Starting with the technical controls already in place means your preparation budget goes to the operational side — which is where the actual audit risk lives.
SOC 2 TSC compliance matrix
| Criterion | Control | Granit module |
|---|---|---|
| CC6.1 — Authentication | JWT Bearer + DPoP + PKCE | Granit.Authentication |
| CC6.1 — Transport encryption | RequireHttpsMetadata = true |
Granit.Authentication |
| CC6.3 — Authorization | Dynamic RBAC/ABAC, runtime permissions | Granit.Authorization |
| CC6.3 — Tenant isolation | Query filters, 3 strategies | Granit.MultiTenancy |
| CC6.6 — Third-party auth | FAPI 2.0 (PAR + DPoP + private_key_jwt) |
Granit.OpenIddict |
| CC6.7 — Encryption at rest | Field-level AES / Vault Transit |
Granit.Encryption, Granit.Vault
|
| CC6.7 — Key management | HashiCorp Vault, auto-rotation | Granit.Vault.HashiCorp |
| CC6.8 — Token protection | BFF pattern, HttpOnly cookies | Granit.Bff |
| CC7.1 — Anomaly detection | Structured logs + metrics + traces | Granit.Observability |
| CC7.2 — Audit trail | Interceptor-based, immutable | Granit.Auditing |
| A1.1 — Availability | Rate limiting, distributed cache |
Granit.RateLimiting, Granit.Caching
|
| A1.2 — Tenant isolation | DB/schema/filter strategies | Granit.MultiTenancy |
| C1.1 — Confidentiality | Field encryption + crypto-shredding |
Granit.Encryption, Granit.Privacy
|
| P4.1 — Data portability |
IPersonalDataProvider aggregation |
Granit.Privacy |
| P8.1 — Right to erasure | Crypto-shredding via Vault key destruction | Granit.Privacy |
TL;DR
SOC 2 Type 2 is a process audit, not a code audit. But the technical controls need to be in place and operating before the observation window starts.
If you're building on .NET, Granit gives you CC6, CC7, and the Confidentiality/Privacy TSC controls out of the box: dynamic RBAC, automatic audit trails, field-level encryption with Vault key management, BFF token protection, and crypto-shredding for GDPR erasure without breaking your audit trail.
The hard part — runbooks, access reviews, pen tests, employee training — is still on you. But starting with a framework that makes the compliant path the default path means you're spending your prep time on the right things.
Full documentation: granit-fx.dev
Source code: github.com/granit-fx/granit-dotnet (Apache 2.0)
Top comments (0)