← Back to list

Creating A 2FA Authentication WEBAPI using .NET and RFC6238

In the modern age of internet, 2FA (2 Factor Authentication) is one of the most important steps to secure your information. While most…

Choco · 2026-07-11 12:43 · 1 claps · 8.4 min read
#webapi #dotnet #totp #security #docker
Open on Medium ↗
Wiki topics: ☁️ · DevOps & Cloud

Creating A 2FA Authentication WEBAPI using .NET and RFC6238

Time-Based One Time Password

Time-Based One Time Password

In the modern age of internet, 2FA (2 Factor Authentication) is one of the most important steps to secure your information. While most people get a code via e-mail or SMS, some websites such as Github use TOTP (Time-Based One Time Password) for authentication. This TOTPs are usually generated via an algorithm called RFC6238. This article will focus on creating a WEBAPI for 2FA with this algorithm on a architectural and security point.

What Is RFC6238?

RFC6238 is a HMAC-TOTP generation algorithm that basically uses a “secret” Base64 string to generate random 6 digit short-lived passwords based on the current Unix time. This enhances security as there is simply no way of getting the same password twice because of the simple fact that the time always moves on. The general time limit for these passwords is 30 seconds. This value has been selected as a balance between security and usability.

The Architecture

This API is based on MCS (Model-Controller-Service) template. We will also create DTOs (Data Transfer Object) to pass data between Service and Controller. This will be the first level of our API

The second level is databases. We will use PostgreSQL and Redis in order to store data efficiently.

The third level of this API is Nginx and SLL Certification. Nginx will work as a Reverse Proxy and SSL will help us with HTTPS.

The fourth and last level of the API is security and container testing. We will use SonarQube Cloud and Trivy tools for scanning and finding vulnerabilities in our code and container.

Building The API And Controller

We will be using .NET to build our WEBAPI as it is sector standart to use. The first command to build our template is:

dotnet new webapi -o "API name"

This will create a weather forecasting API. We can later delete unnecessary files.

We need to create a couple of folders before we write an code.

mkdir Controllers Data DTOs Models Services

Let’s create a file called AuthController.cs and start on writing. First thing to do is extend the ControllerBase class and identify our class as an API controller.

using Authentication.Services;
using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("api/[controller]")]
public class AuthController : ControllerBase
{
  // Endpoints
}

The routing in .NET is done by creating endpoints as POST, GET, DELETE etc. We create methods the same name as the endpoints. Example:

[HttpPost("login")]
public IActionResult Login()
{
  // Logic
}

We will later change the return type as out DTOs. The inner logic code is written after the services are ready to use. I will use 3 endpoints as “register”, “login” and “totp”. You can later add logic and endpoints as you wish.

Creating DTOs

DTOs are used to pass data between service, controller and databases. We will create 4 DTOs to store our information. Let’s create 3 files called AuthResponseDto.cs, LoginRequestDto.cs, RegisterRequestDto.cs and VerifyTotpDto.cs. Let’s focus on LoginRequestDto.cs for now:

using System.ComponentModel;
using System.ComponentModel.DataAnnotations;

public class LoginRequestDto
{
  public required string Email { get; set; }
  public required string Password { get; set; }
}

Since a user needs to enter both email and password in order to login, we need to make these fields required. We also need to check if the email is a legit email and password is censored while writing for security reasons.

// For Email field
[Required(ErrorMessage = "Email is required")]
[EmailAddress(ErrorMessage = "Invalid Email Address")]

// For Password Field
[Required(ErrorMessage = "Password is required")]
[PasswordPropertyText]

We don’t need to check the validity of the password here. It is done by the inner logic of Login. The other DTOs are mostly the same as Login.

Creating The User Model

The models are custom structures representing real-world entities in out API. In our context, we only need one model named UserModel.cs

public class UserModel
{
  public uint Id { get; set; }
  public string Email { get; set; }
  public string PasswordHash { get; set; }
  public string TotpSecret { get; set; }
}

There are couple things to be addressed. First of all; we use unsigned int for IDs since negative ID are not used in real-world. Then we have PasswordHash. Storing passwords in the database is a huge security risk as if someone accesses the database, the password can be stolen. We use one-way hashing algorithms to store and compare passwords for security. The last one is TotpSecret. It is a Base64 string unique to each user. This will be used to create the HMAC-TOTP.

Configuring The Databases

Let’s have a small break on MCS and configure our databases. First we need to download put packages for PostgreSQL and Redis. For PostgreSQL:

dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL
dotnet add package Microsoft.EntityFrameworkCore.Design

And for Redis:

dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis

As for configuration, there are 2 ways of doing it via appsettings.json or .env files. The configuring in Program.cs also changes depending on the way you do it. For appsettings.json way:

{
  "ConnectionStrings": {
    "PostgresConnection": "Host=db;Port=5432;Database=AuthDatabase;Username=postgres;Password="MySecretPassword",
    "RedisConnection": "Port:6379"
  }
}

Asn the .env way is:

POSTGRES_CONNECTION=Host=db;Port=5432;Database=AuthDatabase;Username=postgres;Password=MySecretPassword
POSTGRES_PASSWORD=MySecretPassword
REDIS_CONNECTION=redis:6379

Also worthy to note that in order to use .env files and do auto migration for databases in .NET, we need to download the package DotNetEnv and add InitialCreate via dotnet-ef:

dotnet add package DotNetEnv
dotnet tool install --global dotnet-ef
dotnet ef migrations add InitialCreate
dotnet ef database update

Now we need to create DatabaseContext and Configuration files. Let’s craete 2 files called AuthDbContext.cs and UserConfiguration.cs. The latter needs to be in a seperate folder inside Data.

using Authentication.Models;
using Microsoft.EntityFrameworkCore;

public class AuthDbContext : DbContext
    {
        public AuthDbContext(DbContextOptions<AuthDbContext> options) : base(options)
        {

        }

        public DbSet<userModel> Users => Set<userModel>();

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            base.OnModelCreating(modelBuilder);

            modelBuilder.ApplyConfigurationsFromAssembly(typeof(AuthDbContext).Assembly);
        }


    }

Extending DbContext will let .NET know that this is a database file. Using DbSet<userModel> makes it easier to create user based tables in PostgreSql. Let’s get to the UserConfigurations.cs:

using Authentication.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

public class UserConfigurations : IEntityTypeConfiguration<userModel>
    {
        public void Configure(EntityTypeBuilder<userModel> builder)
        {

            builder.ToTable("Users");

            builder.HasIndex(u => u.Email).IsUnique();

            builder.Property(u => u.Email)
                .IsRequired()
                .HasMaxLength(120);

            builder.Property(u => u.PasswordHash)
                .IsRequired();
        }
    }

This will let us create tables with specific required fields according to the userModel.

Dependency Injection and Service

It’s time for service. For good architecture, we will use dependency injection method. First let’s create an interface service called IAuthService.cs. We will use at least 4 methods in the service:

public interface IAuthService
    {
        Task<bool> IsEmailRegisteredAsync(string email, CancellationToken cancellationToken = default);

        Task<AuthResponseDTO> Register(RegisterDTO registerDTO);

        Task<AuthResponseDTO> Login(LoginRequestDTO loginRequestDTO);

        Task<AuthResponseDTO> verifyTOTP(VerifyTotpDTO verifyTOTPRequestDTO);
    }

We will talk about the methods right after creating AuthService.cs:

using System.Buffers.Binary;
using System.Security.Cryptography;
using System.Text;
using Authentication.Data;
using Authentication.DTOs;
using Authentication.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Distributed;

public class AuthService : IAuthService
{
  private readonly AuthDbContext _context;

  private readonly IDistributedCache _redisDb;

  public AuthService(AuthDbContext context, IDistributedCache redisDb)
  {
     _context = context;
     _redisDb = redisDb;
  }

  // Other Logic
}

This is the base of our service. Here, we will do 3 main things: Generating TOTP, checking if the email is registered or not and filling up the Register, Login and VerifyTotp methods. We will use those methods in controller after implementing the service.

The most important part is generating the TOTP password. This is the whole reason we are using Binary, Cryptography and Text from System. There are a lot of ways to generate it but I will use this one:

public string TOTPGenerator(string secret, int timeStep = 30, int digits = 6)
        {
            byte[] keyBytes = Encoding.UTF8.GetBytes(secret);

            long unixTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
            long counter = unixTime / timeStep;

            byte[] timeBuffer = new byte[8];
            BinaryPrimitives.WriteInt64BigEndian(timeBuffer, counter);

            byte[] hash = HMACSHA256.HashData(keyBytes, timeBuffer);

            int offset = hash[hash.Length - 1] & 0x0f;

            int binaryCode = (hash[offset] & 0x7f) << 24
                           | (hash[offset + 1] & 0xff) << 16
                           | (hash[offset + 2] & 0xff) << 8
                           | (hash[offset + 3] & 0xff);

            int mod  = (int)Math.Pow(10, digits);
            int totp = binaryCode % mod;

            return totp.ToString().PadLeft(digits, '0');    
        }

To explain the code, we get the secret from our user, get the current time in unixTime, use the HMACSHA256 to generate the hash etc. At the end, we will have a 6 digit TOTP. We will use this method in Login. This is also where Redis comes in.

Redis is mostly used for caching information and being able to delete that information based on a time limit. In our case, we will use it for storing TOTP for 30 seconds and checking if this code is used in that 30 second time frame. While verifying totp of the user, we check if it is used:

var isUsed =  await _redisDb.GetStringAsync($"TOTPUsed:{user.Email}", default);

if (!string.IsNullOrEmpty(isUsed))
{
 return new AuthResponseDTO
 {
   IsSuccess = false,
   Message = "TOTP has already been used.",
   Response = "400 Bad Request"
 };
}

Basically, if we find a string “TOTPUsed:{user.Email}”, we decide that this code is used and we send Bad Request. After this, we check the TOTP itself. If it is not used, we create that TOTPUsed after verifying.

var cachedTOTP = await _redisDb.GetStringAsync($"TOTP:{user.Email}", default);

            if (cachedTOTP != verifyTOTPRequestDTO.totpCode)
            {
                return new AuthResponseDTO
                {
                    IsSuccess = false,
                    Message = "Invalid TOTP.",
                    Response = "401 Unauthorized"
                };
            }

            await _redisDb.SetStringAsync($"TOTPUsed:{user.Email}", "used", new DistributedCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(30)
            });

            return new AuthResponseDTO
            {
                IsSuccess = true,
                Message = "TOTP verified successfully.",
                Response = "200 OK"
            };

Using the same verifying logic and writing into the database, you can create other methods as you like.

Containerization, HTTPS And Security

In this section, we will create 2 separate .yaml files and 1 DOCKERFILE. Let’s immediately start with DOCKERFILE:

FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY ["Authentication.csproj", "./"]
RUN dotnet restore "Authentication.csproj"
COPY . .
RUN dotnet publish "Authentication.csproj" -c Release -o /app/build

FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
COPY --from=build /app/build .
ENTRYPOINT ["dotnet", "Authentication.dll"]

This will basically restore the csproj file and build the project. Now at this point; you can either create the .yaml file without nginx container and test the API with sending POST requests, or configure nginx with SSL and test with them. I will configure nginx and SSL then test the API.

To configure the nginx and SSL, we need to create a folder called certs and run the following command in the root of the project:

openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout certs/nginx.key -out certs/nginx.crt -subj "/CN=localhost"

This will create 2 files called nginx.key and nginx.crt. These are our certificate and key for HTTPS. Now to configure nginx. Create a file called nginx.conf on the root of our project:

events{
    worker_connections 1024;
}

http{
    add_header X-Frame-Options DENY;
    add_header X-Content-Type-Options nosniff;
    add_header X-XSS-Protection "1; mode=block";

    upstream api_server {
        server api:8080;
    }

    server {
        listen 80;
        server_name localhost;

        return 301 https://$host$request_uri;
    }

    server {
        listen 443 ssl;
        server_name localhost;

        ssl_certificate /etc/nginx/ssl/nginx.crt;
        ssl_certificate_key /etc/nginx/ssl/nginx.key;

        ssl_protocols TLSv1.2 TLSv1.3;
        ssl_ciphers HIGH:!aNULL:!MD5;

        location / {
            proxy_pass http://api_server;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }
    }
}

This will configure the http and https ports and automatically send the requests to correct ports. Now time for .yaml:

name: webapi

services:
  db:
    image: postgres:16-alpine
    container_name: auth_postgres
    restart: on-failure
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
      - POSTGRES_DB=AuthDatabase 
    networks:
      - private_net
    volumes:
      - postgres-data:/var/lib/postgresql/data
    env_file:
      - .env
  redis:
    image: redis:alpine
    container_name: auth_redis
    restart: on-failure
    networks:
      - private_net
    env_file:
      - .env

  api:
    build:
      context: .
      dockerfile: DOCKERFILE
    container_name: auth_api
    networks:
      - private_net
      - public_net
    depends_on:
      - db
      - redis
    env_file:
      - .env

  nginx:
    image: nginx:alpine
    container_name: auth_nginx
    restart: always
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - ./certs:/etc/nginx/ssl:ro
    networks:
      - public_net
    depends_on:
      - api

networks:
  public_net:
    driver: bridge
  private_net:
    driver: bridge
    internal: true

volumes:
  postgres-data:

We also created 2 networks to seperate inner logic with nginx. The last thing we need to do is configure SonarQube Cloud and Trivy. Create an account and organization with free plan on SonarQube Cloud. We will put the second .yaml file in .github/workflows in order to run every push into main or pull request. First, create a repository secret called SONAR_SECRET and assign it as the token via My Account -> Access Tokens. You don’t need to create GITHUB_TOKEN. You can get the organitazion name and project key from Project Information.

name: Security Scan

on:
  push:
    branches:
      - main
  pull_request:
    types: [opened, synchronize, reopened]

jobs:
  sonarqube_scan:
    name: SonarQube Scan
    runs-on: ubuntu-latest

    steps:
      - name: Build and Analyze
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Setup Java
        uses: actions/setup-java@v4
        with: 
          distribution: 'temurin'
          java-version: '17'

      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '8.0.x'

      - name: Install SonarScanner for .NET
        run: dotnet tool install --global dotnet-sonarscanner

      - name: Build and Analyze via SonarCloud
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

        run: |
          dotnet sonarscanner begin /k:"Project Key" /o:"Organization Name" /d:sonar.login="$SONAR_TOKEN" /d:sonar.host.url="https://sonarcloud.io"
          dotnet build
          dotnet sonarscanner end /d:sonar.login="$SONAR_TOKEN"

      - name: Build Docker Image
        run: |
          docker build -t auth-api:latest -f DOCKERFILE .

      - name: Trivy
        uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1
        with:
          image-ref: auth-api:latest
          format: 'table'
          exit-code: '1'
          ignore-unfixed: true
          severity: 'CRITICAL,HIGH'

Now you just need to create a JSON file and post it using curl or VSCode extentions. Use docker compose up -d — build before testing to up the containers.


메타데이터
post_id
a84d297edcd7
slug
creating-a-2fa-authentication-webapi-using-net-and-rfc6238-a84d297edcd7
url
https://medium.com/@mahmutismail83/creating-a-2fa-authentication-webapi-using-net-and-rfc6238-a84d297edcd7
canonical_url
https://medium.com/@mahmutismail83/creating-a-2fa-authentication-webapi-using-net-and-rfc6238-a84d297edcd7
author_url
https://medium.com/@mahmutismail83
status
ok
fetched_at
2026-07-15 16:48:10