Home β€Ί .NET on AWS β€Ί Using Cognito with .NET

Using Cognito with .NET

Authentication and authorization with Amazon Cognito from .NET: JWT validation, user pools, custom auth flows, and CDK setup.

Cognito from .NET

Amazon Cognito handles user authentication. Sign up, sign in, MFA, token issuance. It's AWS's managed auth service, roughly comparable to Auth0 or Azure AD B2C. You get a user pool (the user directory), an app client (your application's credentials), and JWT tokens that your API validates.

Cognito now has three feature plans (Lite, Essentials, Plus) with Essentials as the default. It includes the new Managed Login UI, passwordless authentication, and multi-region replication. The .NET integration story has improved but still isn't as well-documented as other languages. Here's how it works from the API side.

Architecture Overview

Typical flow:

  1. Client (browser/mobile) authenticates directly with Cognito β†’ gets JWT tokens
  2. Client sends access token in Authorization header
  3. Your .NET API validates the JWT and extracts claims
  4. Authorization decisions based on claims (groups, custom attributes, scopes)

Your API never handles passwords. Cognito does that.

JWT Validation in ASP.NET Core

If you're running in a container (ECS, Fargate):

using Microsoft.AspNetCore.Authentication.JwtBearer;

builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        var region = "us-east-1";
        var userPoolId = "us-east-1_ABC123";
        
        options.Authority = $"https://cognito-idp.{region}.amazonaws.com/{userPoolId}";
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = false, // Cognito access tokens don't include audience
            ValidateLifetime = true,
            ValidIssuer = $"https://cognito-idp.{region}.amazonaws.com/{userPoolId}",
        };
    });

Note: Cognito access tokens don't include an aud (audience) claim. Only ID tokens do. Set ValidateAudience = false for access token validation, or validate the client_id claim manually.

JWT Validation in Lambda

For Lambda functions behind API Gateway, you have two options:

Option 1: API Gateway JWT Authorizer (recommended)

Let API Gateway validate the token before your Lambda even runs. No code needed in your function. The claims arrive in the request context:

public async Task<APIGatewayHttpApiV2ProxyResponse> Handler(
    APIGatewayHttpApiV2ProxyRequest request, ILambdaContext context)
{
    // Claims are already validated by API Gateway
    var userId = request.RequestContext.Authorizer.Jwt.Claims["sub"];
    var groups = request.RequestContext.Authorizer.Jwt.Claims["cognito:groups"];
    
    // Your logic here
}

Option 2: Manual validation in Lambda

If you need more control (custom validation logic, caching JWKS):

using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;

public class TokenValidator
{
    private static readonly ConfigurationManager<OpenIdConnectConfiguration> _configManager;
    private static readonly TokenValidationParameters _validationParams;

    static TokenValidator()
    {
        var region = Environment.GetEnvironmentVariable("AWS_REGION")!;
        var userPoolId = Environment.GetEnvironmentVariable("USER_POOL_ID")!;
        var issuer = $"https://cognito-idp.{region}.amazonaws.com/{userPoolId}";

        _configManager = new ConfigurationManager<OpenIdConnectConfiguration>(
            $"{issuer}/.well-known/openid-configuration",
            new OpenIdConnectConfigurationRetriever(),
            new HttpDocumentRetriever());

        _validationParams = new TokenValidationParameters
        {
            ValidIssuer = issuer,
            ValidateAudience = false,
            ValidateLifetime = true,
            IssuerSigningKeyResolver = (token, securityToken, kid, parameters) =>
            {
                var config = _configManager.GetConfigurationAsync(CancellationToken.None).Result;
                return config.SigningKeys;
            },
        };
    }

    public ClaimsPrincipal? ValidateToken(string token)
    {
        try
        {
            var handler = new JwtSecurityTokenHandler();
            return handler.ValidateToken(token, _validationParams, out _);
        }
        catch (SecurityTokenException)
        {
            return null;
        }
    }
}

Working with Cognito Admin API

For server-side user management (creating users, adding to groups, custom attributes):

using Amazon.CognitoIdentityProvider;
using Amazon.CognitoIdentityProvider.Model;

var cognitoClient = new AmazonCognitoIdentityProviderClient();

// Create a user
await cognitoClient.AdminCreateUserAsync(new AdminCreateUserRequest
{
    UserPoolId = userPoolId,
    Username = email,
    UserAttributes = new List<AttributeType>
    {
        new() { Name = "email", Value = email },
        new() { Name = "email_verified", Value = "true" },
        new() { Name = "custom:tenant_id", Value = tenantId },
    },
    DesiredDeliveryMediums = new List<string> { "EMAIL" },
});

// Add user to a group
await cognitoClient.AdminAddUserToGroupAsync(new AdminAddUserToGroupRequest
{
    UserPoolId = userPoolId,
    Username = email,
    GroupName = "admins",
});

// Get user attributes
var user = await cognitoClient.AdminGetUserAsync(new AdminGetUserRequest
{
    UserPoolId = userPoolId,
    Username = email,
});
var tenantId = user.UserAttributes.First(a => a.Name == "custom:tenant_id").Value;

Multi-Tenant Patterns

For SaaS applications, use a custom attribute (custom:tenant_id) to associate users with tenants:

// Extract tenant from validated token
public static string GetTenantId(ClaimsPrincipal user)
{
    return user.FindFirst("custom:tenant_id")?.Value 
        ?? throw new UnauthorizedAccessException("No tenant claim found");
}

// Use in data access layer
public async Task<List<Order>> GetOrdersAsync(ClaimsPrincipal user)
{
    var tenantId = GetTenantId(user);
    
    // Query scoped to tenant
    return await _repository.QueryAsync(
        pk: $"TENANT#{tenantId}",
        skPrefix: "ORDER#");
}

CDK Setup (C#)

using Amazon.CDK;
using Amazon.CDK.AWS.Cognito;

var userPool = new UserPool(this, "AppUserPool", new UserPoolProps
{
    UserPoolName = "my-app-users",
    SelfSignUpEnabled = true,
    SignInAliases = new SignInAliases { Email = true },
    AutoVerify = new AutoVerifiedAttrs { Email = true },
    PasswordPolicy = new PasswordPolicy
    {
        MinLength = 12,
        RequireLowercase = true,
        RequireUppercase = true,
        RequireDigits = true,
        RequireSymbols = false,
    },
    AccountRecovery = AccountRecovery.EMAIL_ONLY,
    CustomAttributes = new Dictionary<string, ICustomAttribute>
    {
        ["tenant_id"] = new StringAttribute(new StringAttributeProps { Mutable = false }),
    },
    RemovalPolicy = RemovalPolicy.RETAIN,
});

// App client (no secret β€” for public clients like SPAs)
var appClient = userPool.AddClient("WebClient", new UserPoolClientOptions
{
    AuthFlows = new AuthFlow
    {
        UserSrp = true,
    },
    OAuth = new OAuthSettings
    {
        Flows = new OAuthFlows { AuthorizationCodeGrant = true },
        Scopes = new[] { OAuthScope.OPENID, OAuthScope.EMAIL, OAuthScope.PROFILE },
        CallbackUrls = new[] { "https://myapp.com/callback" },
        LogoutUrls = new[] { "https://myapp.com/logout" },
    },
    AccessTokenValidity = Duration.Hours(1),
    IdTokenValidity = Duration.Hours(1),
    RefreshTokenValidity = Duration.Days(30),
});

// Cognito domain for hosted UI
userPool.AddDomain("CognitoDomain", new UserPoolDomainOptions
{
    CognitoDomain = new CognitoDomainOptions
    {
        DomainPrefix = "my-app-auth",
    },
});

// Groups for RBAC
new CfnUserPoolGroup(this, "AdminGroup", new CfnUserPoolGroupProps
{
    GroupName = "admins",
    UserPoolId = userPool.UserPoolId,
    Description = "Application administrators",
});

Tips

  • Use the API Gateway JWT Authorizer when possible. It's faster (no Lambda cold start), cheaper (no invocation cost for invalid tokens), and simpler (no validation code to maintain).
  • Don't store sensitive data in JWT claims. Tokens are signed but not encrypted. Custom attributes like tenant_id are fine; secrets are not.
  • Cognito has hard limits. 40 calls/second for admin APIs (AdminCreateUser, etc.) by default. If you're bulk-importing users, request a limit increase or use the CSV import feature.
  • Refresh tokens live longer than you think. Default is 30 days. If a user's permissions change, they'll still have valid tokens until they expire. For sensitive operations, re-validate against Cognito directly.
  • Consider Cognito triggers for custom logic. Pre-signup validation, post-confirmation actions, pre-token-generation to add custom claims. These are Lambda functions invoked by Cognito at specific points in the auth flow.

Further Reading

Looking for hands-on help? View my .NET on AWS services β†’

Building authentication into your .NET app?

Drop me a message β€” I typically respond within one business day.