Securing Data Transmission in ASP.NET Core Web APIs: A Custom Encryption Pipeline
In modern web applications, protecting sensitive payloads during transit is a baseline security requirement, not a luxury feature. This guide outlines a custom encryption pipeline for ASP.NET Core Web APIs that encrypts request and response bodies through attributes, filters, and shared cryptographic helpers. The result is an opt-in model for protected endpoints without scattering encryption logic across every controller like confetti from a bad architecture review.
Prerequisites
To implement this pattern safely, developers should be comfortable with:
- ASP.NET Core filters and request pipeline behavior
- Symmetric encryption concepts, especially AES
- .NET 6+ development workflows
- REST API conventions
- Client/server coordination for encrypted payload exchange
Architectural Overview
The implementation uses two server-side components inside the ASP.NET Core execution pipeline:
-
EncryptedTransportAttribute: Declarative marker placed on controllers or actions that must use encrypted transport -
EncryptedTransportFilter: Runtime filter that decrypts inbound data and encrypts outbound payloads
This structure keeps the controller layer clean while moving the cryptographic work into a single, reusable interception point. The endpoint code stays focused on business logic instead of manually decrypting strings and wondering why every action suddenly looks like a CTF challenge.
Implementation Breakdown
Component 1: EncryptedTransportAttribute
This attribute acts as a filter factory. It resolves encryption configuration from DI and builds a fresh filter instance per request.
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public sealed class EncryptedTransportAttribute : Attribute, IFilterFactory
{
public bool IsReusable => false;
public IFilterMetadata CreateInstance(IServiceProvider serviceProvider)
{
var encryptionOptions = serviceProvider
.GetRequiredService<IOptions<ApiEncryptionOptions>>();
// Fresh filter instance per request: less shared state, fewer haunted bugs.
return new EncryptedTransportFilter(encryptionOptions.Value);
}
}
What this component does:
- Allows opt-in encryption at either controller or action level
- Pulls encryption settings from dependency injection
- Ensures the filter gets a clean cryptographic context per request
Component 2: EncryptedTransportFilter
This filter wraps the request and response streams so decryption happens before controller execution and encryption happens before the response leaves the server.
public sealed class EncryptedTransportFilter : IAsyncResourceFilter
{
private readonly Aes _aesProvider;
public EncryptedTransportFilter(ApiEncryptionOptions options)
{
_aesProvider = CreateAesProvider(options.SharedSecret);
}
public async Task OnResourceExecutionAsync(
ResourceExecutingContext context,
ResourceExecutionDelegate next)
{
context.HttpContext.Request.Body =
CreateDecryptionStream(context.HttpContext.Request.Body);
context.HttpContext.Response.Body =
CreateEncryptionStream(context.HttpContext.Response.Body);
if (context.HttpContext.Request.QueryString.HasValue)
{
var encryptedQuery = context.HttpContext.Request.QueryString.Value![1..];
var decryptedQuery = DecryptText(encryptedQuery);
context.HttpContext.Request.QueryString =
new QueryString($"?{decryptedQuery}");
}
await next();
// Always dispose wrapped streams. Streams remember everything, and not in a healthy way.
await context.HttpContext.Request.Body.DisposeAsync();
await context.HttpContext.Response.Body.DisposeAsync();
}
private CryptoStream CreateEncryptionStream(Stream responseStream)
{
var encryptor = _aesProvider.CreateEncryptor();
var base64Encoder = new ToBase64Transform();
var base64Stream = new CryptoStream(
responseStream,
base64Encoder,
CryptoStreamMode.Write);
return new CryptoStream(base64Stream, encryptor, CryptoStreamMode.Write);
}
private CryptoStream CreateDecryptionStream(Stream requestStream)
{
var decryptor = _aesProvider.CreateDecryptor();
var base64Decoder = new FromBase64Transform(
FromBase64TransformMode.IgnoreWhiteSpaces);
var decodedStream = new CryptoStream(
requestStream,
base64Decoder,
CryptoStreamMode.Read);
return new CryptoStream(decodedStream, decryptor, CryptoStreamMode.Read);
}
private string DecryptText(string encryptedText)
{
using var cipherBuffer =
new MemoryStream(Convert.FromBase64String(encryptedText));
using var cryptoStream = new CryptoStream(
cipherBuffer,
_aesProvider.CreateDecryptor(),
CryptoStreamMode.Read);
using var textReader = new StreamReader(cryptoStream);
return textReader.ReadToEnd();
}
}
Runtime flow:
- The request body stream is replaced with a decryption stream
- The response body stream is replaced with an encryption stream
- Encrypted query-string values are decrypted before model binding runs
- Wrapped streams are disposed after the action completes
Cryptographic Setup
Use a single helper to build the AES provider from configuration so the encryption details remain centralized.
private static Aes CreateAesProvider(string sharedSecret)
{
var normalizedSecret = sharedSecret.PadRight(32, '0');
var aes = Aes.Create();
aes.Key = Encoding.UTF8.GetBytes(normalizedSecret[..32]);
aes.IV = Encoding.UTF8.GetBytes(normalizedSecret[..16]);
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;
// Centralize the crypto setup here. Future-you should not need a treasure map for this.
return aes;
}
Current security characteristics:
- Uses a 256-bit AES key derived from the configured shared secret
- Uses CBC mode with PKCS7 padding for block encryption
- Derives the IV from the same secret material for deterministic interoperability
Note: this article preserves the same pattern as the current implementation. In production systems, key derivation and IV handling usually deserve a stronger design than "slice the secret and hope for the best."
Applying the Filter
Controller-Level Usage
Apply the attribute at controller level when every endpoint in the controller should use encrypted transport.
[EncryptedTransport]
[Route("api/[controller]")]
public sealed class SecurePayloadController : ControllerBase
{
[HttpPost]
public IActionResult Submit([FromBody] SensitivePayload request)
{
return Ok(request.Process());
}
}
Action-Level Usage
Apply the attribute at action level when only specific endpoints need encrypted transport.
[Route("api/[controller]")]
public sealed class SecurePayloadController : ControllerBase
{
[EncryptedTransport]
[HttpPost]
public IActionResult Submit([FromBody] SensitivePayload request)
{
return Ok(request.Process());
}
}
Client-Side Integration
The browser client must mirror the server-side encryption rules. A shared interceptor keeps that logic centralized instead of duplicating it across every request call.
Axios Interceptor
import axios from 'axios';
import { API_ROOT } from '../../constants/NetworkConfig';
import {
encryptTransportPayload,
decryptTransportPayload
} from '../../utilities/transportCrypto';
const encryptedApiClient = axios.create({ baseURL: API_ROOT });
encryptedApiClient.interceptors.request.use((requestConfig) => {
const [basePath, rawQuery] = requestConfig.url
? requestConfig.url.split('?')
: [];
if (rawQuery) {
requestConfig.url = `${basePath}?${encryptTransportPayload(rawQuery)}`;
}
if (requestConfig.data) {
requestConfig.headers['Content-Type'] = 'application/json';
requestConfig.transformRequest = encryptTransportPayload;
}
// Response decryption belongs here, not sprinkled across components like seasoning.
requestConfig.transformResponse = decryptTransportPayload;
return requestConfig;
});
What the interceptor handles:
- Query-string encryption
- Request-body encryption
- Response-body decryption
Client Cryptographic Utilities
import CryptoJS from 'crypto-js';
import { TRANSPORT_SHARED_SECRET } from '../constants/appSettings';
const normalizedSecret = TRANSPORT_SHARED_SECRET.padEnd(32, '0');
const aesKey = CryptoJS.enc.Utf8.parse(normalizedSecret.substring(0, 32));
const aesIv = CryptoJS.enc.Utf8.parse(normalizedSecret.substring(0, 16));
const aesTransportConfig = {
iv: aesIv,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
};
export const encryptTransportPayload = (payload) => {
if (payload === null || payload === undefined) return payload;
const serializedPayload = CryptoJS.enc.Utf8.parse(
typeof payload === 'string' ? payload : JSON.stringify(payload)
);
const encryptedPayload = CryptoJS.AES.encrypt(
serializedPayload,
aesKey,
aesTransportConfig
);
return CryptoJS.enc.Base64.stringify(encryptedPayload.ciphertext);
};
export const decryptTransportPayload = (encodedPayload) => {
if (!encodedPayload) return encodedPayload;
try {
const cipherBytes = CryptoJS.enc.Base64.parse(encodedPayload);
const decryptedText = CryptoJS.AES.decrypt(
{ ciphertext: cipherBytes },
aesKey,
aesTransportConfig
).toString(CryptoJS.enc.Utf8);
try {
return JSON.parse(decryptedText);
} catch (_) {
// Not every payload dreams of becoming JSON.
return decryptedText;
}
} catch (_) {
return encodedPayload;
}
};
Client-side considerations:
- The client and server must use the same secret, key shaping, and IV rules
- Base64 encoding keeps encrypted output safe for HTTP transport
- The utility gracefully handles both plain strings and JSON payloads
Security Analysis
This pattern improves confidentiality for transported payload data in several ways:
- Payload Confidentiality: Encrypted message bodies are not readable in transit without the shared secret
- Transport Consistency: Request, response, and query handling all follow the same protection model
- Centralized Enforcement: Cryptographic behavior is enforced by infrastructure code instead of relying on controller authors to remember it
- Minimal Controller Impact: Endpoints opt in declaratively and keep their action logic clean
That said, this design should be treated as an application-layer encryption pattern, not a replacement for HTTPS. TLS still does the real heavy lifting on the wire. This layer is additional protection, not a license to make your reverse proxy cry.
Operational Considerations
- Key Management: Store and rotate the shared secret using a secure secrets system
- Performance Overhead: Encryption and decryption add latency and CPU cost to every protected request
- Diagnostics: Logging and tracing become trickier when payloads are encrypted at the application layer
- Client Coordination: Mobile, web, and third-party clients must all implement the same cryptographic contract
- Failure Handling: Decryption failures should return controlled errors, not half-useful stack traces
Conclusion
This pattern demonstrates a practical way to add application-level payload encryption to ASP.NET Core Web APIs while keeping endpoint code relatively clean. By combining an attribute-based activation model with a reusable resource filter and shared client utilities, teams can enforce encrypted transport behavior without duplicating cryptographic logic across the codebase.
Used carefully, this approach can add an extra protection layer for highly sensitive workflows such as finance, healthcare, or regulated internal systems. It works best when paired with strong key management, disciplined client coordination, and the humility to remember that cryptography gets dangerous the moment it starts feeling "simple."
Top comments (2)
Hy Its really nice and helpful, same solution I am looking for is to Encrypt application end to end, application scale is very large and I don't want to go to each page and change the script
in this how front Application is encrypting parameters sending to controllers?
Edited the main article with examples. Hope this helps.