This document provides a design template for implementing secure authentication with backend-enforced two-factor authentication (2FA) and OAuth2 SSO integration. The pattern demonstrates how to build authentication that cannot be bypassed via direct API calls.
This document was prepared by Yoctobe Technical Team.
Technology Stack:
- Backend: .NET Core (C#)
- Frontend: React with Redux
- Authentication: OAuth2 SSO + Email-based 2FA
- Database: Entity Framework Core
Architecture Pattern
Core Security Principles
- Backend-Enforced 2FA: JWT tokens are only issued AFTER 2FA verification
- OAuth2 SSO Bypass: OAuth2 providers handle MFA, so internal 2FA is skipped
- No Frontend-Only Security: All security checks happen on the backend
- State Management: Proper handling of authentication states in Redux
Part 1: Backend-Enforced 2FA Authentication
Problem Statement
Frontend-only 2FA can be bypassed via Postman or direct API calls. This pattern enforces 2FA at the backend level by only issuing JWT tokens after successful 2FA verification.
Solution Pattern
Step 1: Login Endpoint Returns Pending State
Endpoint: POST /api/v1/auth/signin
Backend Logic:
[HttpPost("signin")]
public async Task<IActionResult> Signin([FromBody] LoginRequestDto loginDto)
{
// Find user
var user = await _userManager.FindByEmailAsync(loginDto.Email);
if (user == null)
{
return BadRequest(new { detail = "Invalid login" });
}
// Validate password
var isValid = await _userManager.CheckPasswordAsync(user, loginDto.Password);
if (!isValid)
{
return BadRequest(new { detail = "Invalid login" });
}
// Check if 2FA is enabled for this user
var isTwoFactorEnabled = await _userManager.GetTwoFactorEnabledAsync(user);
if (isTwoFactorEnabled)
{
// Generate 2FA code
var twoFactorToken = await _userManager.GenerateTwoFactorTokenAsync(user, "Email");
// Send code via email service
await _mailService.SendMailAsync(new EmailDto
{
EmailToId = user.Email,
EmailSubject = "Your Verification Code",
EmailBody = $"Your two-factor authentication code is: {twoFactorToken}"
});
// Return pending state - NO JWT TOKEN YET
return Ok(new TwoFactorPendingDto
{
Email = user.Email,
RequiresTwoFactor = true
});
}
// If 2FA is not enabled, proceed with regular sign-in
return await ProcessSignin(loginDto, null);
}
DTO:
public class TwoFactorPendingDto
{
public string Email { get; set; }
public bool RequiresTwoFactor { get; set; }
}
LoginResponseDto (for successful login):
public class LoginResponseDto
{
public string Token { get; set; } // JWT token
public string Email { get; set; }
public List<string> Roles { get; set; }
public string Username { get; set; }
// ... other user properties
}
Step 2: Verify Code and Issue JWT
Endpoint: POST /api/v1/auth/verifycode
Backend Logic:
[HttpPost("verifycode")]
public async Task<IActionResult> VerifyCode([FromBody] VerifyCodeDto verifyCodeDto)
{
var user = await _userManager.FindByEmailAsync(verifyCodeDto.Email);
if (user == null)
{
return BadRequest(new { detail = "Invalid email" });
}
// Verify 2FA code
var isValid = await _userManager.VerifyTwoFactorTokenAsync(user, "Email", verifyCodeDto.Code);
if (!isValid)
{
return BadRequest(new { detail = "Invalid code" });
}
// NOW generate JWT token (only after 2FA verification)
var roles = await _userManager.GetRolesAsync(user);
var token = _tokenService.CreateJWTToken(user, roles.ToList());
// Return complete login response with JWT
return Ok(new LoginResponseDto
{
Token = token,
Email = user.Email,
Roles = roles.ToList(),
Username = user.UserName
// ... other user data
});
}
VerifyCodeDto:
public class VerifyCodeDto
{
[Required]
public string Email { get; set; }
[Required]
public string Code { get; set; }
}
Step 3: Helper Method for Regular Sign-In
private async Task<IActionResult> ProcessSignin(LoginRequestDto loginDto, string? provider)
{
var user = await _userManager.FindByEmailAsync(loginDto.Email);
var roles = await _userManager.GetRolesAsync(user);
var token = _tokenService.CreateJWTToken(user, roles.ToList());
return Ok(new LoginResponseDto
{
Token = token,
Email = user.Email,
Roles = roles.ToList(),
Username = user.UserName
});
}
Frontend Pattern
Login Action Handler
// actions/userActions.js
export const login = (email, password) => async (dispatch) => {
try {
dispatch({ type: USER_LOGIN_REQUEST });
const config = {
headers: { 'Content-Type': 'application/json' }
};
const { data } = await axios.post(
'/api/v1/auth/signin',
{ email, password },
config
);
// Check if 2FA required
if (data.requiresTwoFactor) {
// Store pending state - NO TOKEN YET
localStorage.setItem('2faPendingEmail', data.email);
dispatch({
type: USER_LOGIN_SUCCESS,
payload: {
email: data.email,
requiresTwoFactor: true
}
});
// Redirect to 2FA screen
return;
}
// Regular login - full token received
dispatch({ type: USER_LOGIN_SUCCESS, payload: data });
localStorage.setItem('userInfo', JSON.stringify(data));
dispatch(getMeDetails()); // Fetch user profile
} catch (error) {
dispatch({
type: USER_LOGIN_FAIL,
payload: error.response?.data?.detail || error.message
});
}
};
2FA Verification Screen
// screens/TwoFACodeRequestScreen.js
const handle2FACodeSubmission = async (e) => {
e.preventDefault();
setLoading(true);
try {
const response = await axios.post('/api/v1/auth/verifycode', {
email: email,
code: code
});
// Backend returns full LoginResponseDto with JWT
const userData = response.data;
setLoading(false);
setTFAMessage("✅ 2FA code verified successfully. Redirecting...");
// Update Redux store with complete user info (including JWT)
dispatch({
type: USER_LOGIN_SUCCESS,
payload: userData
});
// Store in localStorage
localStorage.setItem('userInfo', JSON.stringify(userData));
localStorage.setItem('2fauthenticated', 'true');
localStorage.removeItem('2faPendingEmail');
// Fetch user profile details
await dispatch(getMeDetails());
// Redirect to dashboard
setTimeout(() => {
history.push('/dashboard');
}, 2000);
} catch (error) {
setLoading(false);
setTFAMessage("❌ 2FA code verification failed.");
}
};
Redux State Management
// reducers/userReducers.js
export const userLoginReducer = (state = {}, action) => {
switch (action.type) {
case USER_LOGIN_REQUEST:
return { loading: true };
case USER_LOGIN_SUCCESS:
return {
loading: false,
userInfo: action.payload,
// Check if 2FA is pending
requiresTwoFactor: action.payload.requiresTwoFactor || false
};
case USER_LOGIN_FAIL:
return { loading: false, error: action.payload };
case USER_LOGOUT:
return {};
default:
return state;
}
};
Component Guard Pattern
// components/ProtectedComponent.jsx
function ProtectedComponent() {
const userLogin = useSelector(state => state.userLogin);
const { userInfo, requiresTwoFactor } = userLogin;
// Don't render if 2FA is pending
if (!userInfo || requiresTwoFactor) {
return <div>Please complete 2FA verification...</div>;
}
// Don't render if no token
if (!userInfo.token) {
return null;
}
// Safe to render - user is fully authenticated
return <div>Protected Content</div>;
}
Part 2: OAuth2 SSO Integration Pattern
Problem Statement
OAuth2 providers (like NHS SSO) already handle multi-factor authentication at the identity provider level. We should skip internal 2FA for OAuth2 logins and issue JWT tokens immediately after successful OAuth callback.
Solution Pattern
Step 1: OAuth2 Configuration
appsettings.json:
{
"OAuth2SSO": {
"ClientId": "your-client-id",
"ClientSecret": "your-client-secret",
"AuthorizationEndpoint": "https://identity-provider.com/authorize",
"TokenEndpoint": "https://identity-provider.com/token",
"UserInformationEndpoint": "https://identity-provider.com/userinfo",
"CallbackUri": "https://yourapp.com/oauth2/callback",
"Scope": "openid profile email"
}
}
Step 2: Backend OAuth Flow
Initiate OAuth (Frontend calls this)
[HttpGet("oauth/initiate")]
public IActionResult InitiateOAuth()
{
var state = GenerateRandomState();
HttpContext.Session.SetString("oauth_state", state);
var authUrl = $"{_config["OAuth2SSO:AuthorizationEndpoint"]}?" +
$"response_type=code&" +
$"client_id={_config["OAuth2SSO:ClientId"]}&" +
$"scope={_config["OAuth2SSO:Scope"]}&" +
$"redirect_uri={Uri.EscapeDataString(_config["OAuth2SSO:CallbackUri"])}&" +
$"state={state}";
return Ok(new { url = authUrl });
}
Handle OAuth Callback
[HttpGet("/oauth2/callback")]
public async Task<IActionResult> OAuthCallback(
[FromQuery] string code,
[FromQuery] string state)
{
// Validate state (CSRF protection)
var storedState = HttpContext.Session.GetString("oauth_state");
if (state != storedState)
{
return BadRequest("Invalid state parameter");
}
// Exchange authorization code for access token
var tokenResponse = await ExchangeCodeForToken(code);
var accessToken = tokenResponse["access_token"];
// Get user information from OAuth provider
var userInfo = await GetUserInfoFromProvider(accessToken);
// Find or create user in your system
var user = await FindOrCreateUser(userInfo);
// OAuth2 is already secure (handled by identity provider)
// Skip 2FA and issue JWT immediately
var roles = await _userManager.GetRolesAsync(user);
var jwt = _tokenService.CreateJWTToken(user, roles.ToList());
return Ok(new LoginResponseDto
{
Token = jwt,
Email = userInfo.Email,
Roles = roles.ToList(),
Username = userInfo.Username
});
}
Helper Methods
private async Task<Dictionary<string, string>> ExchangeCodeForToken(string code)
{
var client = new HttpClient();
var request = new HttpRequestMessage(HttpMethod.Post, _config["OAuth2SSO:TokenEndpoint"]);
request.Content = new FormUrlEncodedContent(new[]
{
new KeyValuePair<string, string>("grant_type", "authorization_code"),
new KeyValuePair<string, string>("code", code),
new KeyValuePair<string, string>("redirect_uri", _config["OAuth2SSO:CallbackUri"]),
new KeyValuePair<string, string>("client_id", _config["OAuth2SSO:ClientId"]),
new KeyValuePair<string, string>("client_secret", _config["OAuth2SSO:ClientSecret"])
});
var response = await client.SendAsync(request);
var content = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<Dictionary<string, string>>(content);
}
private async Task<UserInfo> GetUserInfoFromProvider(string accessToken)
{
var client = new HttpClient();
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", accessToken);
var response = await client.GetAsync(_config["OAuth2SSO:UserInformationEndpoint"]);
var content = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<UserInfo>(content);
}
private async Task<IdentityUser> FindOrCreateUser(UserInfo oauthUserInfo)
{
var user = await _userManager.FindByEmailAsync(oauthUserInfo.Email);
if (user == null)
{
// Auto-register new user
user = new IdentityUser
{
Email = oauthUserInfo.Email,
UserName = oauthUserInfo.Email,
EmailConfirmed = true // OAuth providers verify email
};
var result = await _userManager.CreateAsync(user);
if (!result.Succeeded)
{
throw new Exception("Failed to create user");
}
// Assign default role (e.g., "UnauthorizedUser" - requires admin approval)
await _userManager.AddToRoleAsync(user, "UnauthorizedUser");
}
return user;
}
Step 3: Frontend OAuth Flow
OAuth Login Component
// components/OAuthLogin.component.jsx
import React, { useEffect, useState } from 'react';
import { useDispatch } from 'react-redux';
import { useHistory } from 'react-router-dom';
import axios from 'axios';
import { USER_LOGIN_SUCCESS } from '../constants/userConstants';
import { getMeDetails } from '../actions/userActions';
const OAuthLogin = ({ onLoginSuccess, redirect }) => {
const dispatch = useDispatch();
const history = useHistory();
const [listening, setListening] = useState(false);
const [loginWindow, setLoginWindow] = useState(null);
const handleOAuthLogin = async () => {
try {
// Get OAuth authorization URL from backend
const { data } = await axios.get('/api/v1/auth/oauth/initiate');
// Open OAuth provider in popup window
const popup = window.open(
data.url,
'OAuth Login',
'width=600,height=700,scrollbars=yes'
);
setLoginWindow(popup);
setListening(true);
} catch (error) {
console.error('OAuth initiation failed:', error);
}
};
useEffect(() => {
if (!listening) return;
const handleMessage = async (event) => {
// Verify origin for security
if (event.origin !== window.location.origin) return;
// Check if message contains OAuth token
if (event.data && event.data.Token) {
const userInfo = {
token: event.data.Token,
email: event.data.Email,
roles: event.data.Roles,
username: event.data.Username
};
// Update Redux store
dispatch({
type: USER_LOGIN_SUCCESS,
payload: userInfo
});
// Store in localStorage
localStorage.setItem('userInfo', JSON.stringify(userInfo));
localStorage.setItem('2fauthenticated', 'true');
// Fetch user profile details
await dispatch(getMeDetails());
// Close popup
if (loginWindow) {
loginWindow.close();
}
// Call success callback with redirect path
if (onLoginSuccess) {
onLoginSuccess(userInfo, redirect);
}
setListening(false);
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [listening, loginWindow, dispatch, onLoginSuccess, redirect]);
return (
<button onClick={handleOAuthLogin} className="oauth-login-button">
Login with OAuth2 SSO
</button>
);
};
export default OAuthLogin;
OAuth Callback Handler (Backend sends message to parent)
// This script runs in the OAuth callback popup window
// After backend processes OAuth and returns LoginResponseDto
window.opener.postMessage({
Token: userData.token,
Email: userData.email,
Roles: userData.roles,
Username: userData.username
}, window.location.origin);
window.close();
Login Screen Integration
// screens/LoginScreen.js
function LoginScreen({ history, location }) {
const redirect = location.search ? location.search.split('=')[1] : '/dashboard';
const handleLoginSuccess = (userInfo, redirectPath) => {
history.push(redirectPath || '/dashboard');
};
return (
<div>
{/* Email/Password Login */}
<EmailPasswordLogin />
{/* OAuth SSO Login */}
<OAuthLogin
onLoginSuccess={(userInfo) => handleLoginSuccess(userInfo, redirect)}
redirect={redirect}
/>
</div>
);
}
Part 3: Combined Authentication Flow
Flow Diagram
┌─────────────────┐
│ User Login │
└────────┬────────┘
│
┌────▼────┐
│ Method?│
└────┬────┘
│
┌────┴────┐
│ │
┌───▼───┐ ┌──▼────┐
│Email/ │ │OAuth2 │
│Pass │ │ SSO │
└───┬───┘ └───┬───┘
│ │
│ ┌────▼────────────┐
│ │OAuth Callback │
│ │Issue JWT │
│ │(No 2FA) │
│ └─────────────────┘
│
┌───▼──────────────┐
│Check 2FA Enabled?│
└───┬──────────────┘
│
│ Yes
┌───▼──────────────┐
│Send 2FA Code │
│Return Pending │
│(No JWT) │
└───┬──────────────┘
│
┌───▼──────────────┐
│User Enters Code │
└───┬──────────────┘
│
┌───▼──────────────┐
│Verify Code │
│Issue JWT │
└──────────────────┘
Backend Controller Structure
[ApiController]
[Route("api/v1/[controller]")]
public class AuthController : ControllerBase
{
// Email/Password Login (with 2FA)
[HttpPost("signin")]
public async Task<IActionResult> Signin([FromBody] LoginRequestDto dto)
{
// Returns TwoFactorPendingDto if 2FA enabled
// Returns LoginResponseDto if 2FA disabled
}
// 2FA Verification
[HttpPost("verifycode")]
public async Task<IActionResult> VerifyCode([FromBody] VerifyCodeDto dto)
{
// Returns LoginResponseDto with JWT after verification
}
// OAuth Initiation
[HttpGet("oauth/initiate")]
public IActionResult InitiateOAuth()
{
// Returns OAuth authorization URL
}
// OAuth Callback
[HttpGet("/oauth2/callback")]
public async Task<IActionResult> OAuthCallback([FromQuery] string code, [FromQuery] string state)
{
// Returns LoginResponseDto with JWT (no 2FA)
}
}
Part 4: Frontend State Management
Redux Store Structure
// store.js
const reducer = combineReducers({
userLogin: userLoginReducer, // Authentication state
userDetails: userDetailsReducer, // User profile
// ... other reducers
});
User Login State
// Initial state
{
loading: false,
userInfo: null,
requiresTwoFactor: false,
error: null
}
// After email/password login (2FA required)
{
loading: false,
userInfo: { email: "user@example.com", requiresTwoFactor: true },
requiresTwoFactor: true,
error: null
}
// After 2FA verification or OAuth login
{
loading: false,
userInfo: {
token: "jwt-token-here",
email: "user@example.com",
roles: ["User"],
requiresTwoFactor: false
},
requiresTwoFactor: false,
error: null
}
Component Guards
// Pattern for protecting components
function ProtectedComponent() {
const { userInfo, requiresTwoFactor } = useSelector(state => state.userLogin);
// Guard 1: Check if 2FA is pending
if (requiresTwoFactor) {
return <Redirect to="/2fa" />;
}
// Guard 2: Check if user is authenticated
if (!userInfo || !userInfo.token) {
return <Redirect to="/login" />;
}
// Guard 3: Check role permissions
if (!userInfo.roles || !userInfo.roles.includes("RequiredRole")) {
return <div>Access Denied</div>;
}
// Safe to render
return <div>Protected Content</div>;
}
Security Best Practices
Implemented Patterns
- Backend-Enforced 2FA: JWT only issued after verification
- OAuth2 Trust: Skip 2FA for OAuth2 (provider handles MFA)
- State Validation: CSRF protection in OAuth flow
- Token Security: JWT tokens with proper expiration
- Session Management: Proper cleanup on logout
- Error Handling: Clear, actionable error messages
Additional Recommendations
- Rate limiting on login attempts
- Account lockout after failed attempts
- Email notifications for new logins
- Device fingerprinting
- IP-based anomaly detection
- Refresh token rotation
- Logout from all devices option
Common Pitfalls to Avoid
Don’t Do This
// BAD: Issuing JWT before 2FA
var token = CreateJWTToken(user);
if (user.Requires2FA) {
Send2FACode(user);
}
return Ok(new { Token = token }); // Token issued without 2FA!
Do This Instead
// GOOD: 2FA first, then JWT
if (user.Requires2FA) {
Send2FACode(user);
return Ok(new { RequiresTwoFactor = true }); // No token yet
}
var token = CreateJWTToken(user); // Only after 2FA
return Ok(new { Token = token });
Don’t Do This
// BAD: Frontend-only 2FA check
if (userInfo.requiresTwoFactor) {
// Still have token, can bypass 2FA
return <Dashboard />;
}
Do This Instead
// GOOD: Backend never issues token until 2FA verified
if (userInfo.requiresTwoFactor) {
return <Redirect to="/2fa" />; // No token, must verify
}
Troubleshooting Guide
Issue: “2FA code invalid”
Possible Causes:
- Server time synchronization (NTP) mismatch
- Code expired (check
UserManager.Options.Tokens.TokenLifespan) - Email delivery delay (check spam folder)
- Code entered incorrectly (case-sensitive)
Solutions:
- Sync server time with NTP
- Increase token lifespan if needed
- Check email service logs
- Verify code format matches exactly
Issue: “OAuth callback fails”
Possible Causes:
- Redirect URI mismatch
- Invalid state parameter
- ClientId/ClientSecret incorrect
- CORS issues
Solutions:
- Verify redirect URI matches exactly in OAuth provider portal
- Check state parameter validation logic
- Verify credentials in configuration
- Check CORS headers in OAuth provider
Issue: “Token not found after 2FA”
Possible Causes:
- Redux state not updated
- localStorage not synced
- Component re-rendering before state update
Solutions:
- Verify
USER_LOGIN_SUCCESSdispatched after 2FA - Check localStorage after successful verification
- Add loading state during transition
Testing Patterns
Backend Testing
[Fact]
public async Task Signin_With2FAEnabled_ReturnsPendingState()
{
// Arrange
var user = CreateUserWith2FAEnabled();
var loginDto = new LoginRequestDto { Email = user.Email, Password = "Password123!" };
// Act
var result = await _controller.Signin(loginDto);
// Assert
var okResult = Assert.IsType<OkObjectResult>(result);
var response = Assert.IsType<TwoFactorPendingDto>(okResult.Value);
Assert.True(response.RequiresTwoFactor);
Assert.Null(response.Token); // No token yet
}
[Fact]
public async Task VerifyCode_ValidCode_ReturnsJWT()
{
// Arrange
var user = CreateUserWith2FAEnabled();
var code = await Generate2FACode(user);
// Act
var result = await _controller.VerifyCode(new VerifyCodeDto { Email = user.Email, Code = code });
// Assert
var okResult = Assert.IsType<OkObjectResult>(result);
var response = Assert.IsType<LoginResponseDto>(okResult.Value);
Assert.NotNull(response.Token); // JWT issued
Assert.False(response.RequiresTwoFactor);
}
Frontend Testing
describe('Authentication Flow', () => {
it('should return pending state when 2FA required', async () => {
const response = await login('user@example.com', 'password');
expect(response.requiresTwoFactor).toBe(true);
expect(response.token).toBeUndefined();
});
it('should return JWT after 2FA verification', async () => {
await login('user@example.com', 'password');
const response = await verifyCode('user@example.com', '123456');
expect(response.token).toBeDefined();
expect(response.requiresTwoFactor).toBe(false);
});
it('should skip 2FA for OAuth login', async () => {
const response = await oauthLogin();
expect(response.token).toBeDefined();
expect(response.requiresTwoFactor).toBe(false);
});
});
Key Takeaways for Junior Developers
- Security First: Always enforce security at the backend, never trust frontend validation alone
- 2FA Flow: Understand the two-step process – login → verify → JWT
- OAuth2 Pattern: OAuth2 is already secure, no need for additional 2FA
- State Management: Properly handle authentication states in Redux
- Error Handling: Always return clear, actionable error messages
- Testing: Test both success and failure paths
- Documentation: Document authentication flows for team reference
Pattern Reusability
This pattern can be adapted for:
- Any application requiring backend-enforced 2FA
- OAuth2 SSO integration with any provider (Google, Microsoft, NHS, etc.)
- Multi-tenant applications with different auth requirements
- Applications requiring role-based access control
- Systems needing audit trails for authentication
Key Adaptation Points:
- Replace OAuth provider endpoints with your provider’s URLs
- Adjust 2FA method (SMS, TOTP, etc.) if not using email
- Customize user roles and permissions for your domain
- Modify token expiration based on security requirements

