DEV Community

ZèD
ZèD

Posted on • Edited on

Implementing HTTP Request and Response Encryption in ASP.NET Core with Custom Attributes

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:

  1. EncryptedTransportAttribute: Declarative marker placed on controllers or actions that must use encrypted transport
  2. 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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

Runtime flow:

  1. The request body stream is replaced with a decryption stream
  2. The response body stream is replaced with an encryption stream
  3. Encrypted query-string values are decrypted before model binding runs
  4. 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;
}
Enter fullscreen mode Exit fullscreen mode

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());
    }
}
Enter fullscreen mode Exit fullscreen mode

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());
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
});
Enter fullscreen mode Exit fullscreen mode

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;
  }
};
Enter fullscreen mode Exit fullscreen mode

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:

  1. Payload Confidentiality: Encrypted message bodies are not readable in transit without the shared secret
  2. Transport Consistency: Request, response, and query handling all follow the same protection model
  3. Centralized Enforcement: Cryptographic behavior is enforced by infrastructure code instead of relying on controller authors to remember it
  4. 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)

Collapse
 
hammad_uppal_9fc5670479eb profile image
Hammad Uppal

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?

Collapse
 
imzihad21 profile image
ZèD • Edited

Edited the main article with examples. Hope this helps.