F# Giraffe Authentication with ASP.NET Core Identity and Entity Framework Core

By Tosh N. for Carpe Noctem.

Changelog:

Introduction

This article will cover how to create a 'traditional' username and password protected website, including user registration, login/logout, email verification, and a user profile page. We will use the Giraffe framework for F#, along with ASP.NET Core Identity ("Identity" from now on) and Entity Framework Core ("EF Core") as the data store.

This is Part 4 in a series of Giraffe/F# development articles, and I'll assume you're familiar with basic Giraffe development. The previous parts are Part 1 - ASP.NET Core for Giraffe, Part 2 - HttpHandlers, and Part 3 - JWT Auth.

The objective of this article are to:

Authentication is a huge topic, so here are a few caveats to limit the scope of this article:

Working example project can be found in this repo.

Packages

Let's install some packages first. Note that I am specifying version numbers meant for .NET 5 for these packages (except Microsoft.AspNetCore.Identity, which is apparently version-agnostic) because Giraffe's project template is still .NET 5 at the time of writing. Please adjust accordingly in the future.

dotnet tool install --global dotnet-ef
dotnet add package Microsoft.AspNetCore.Identity 
dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore --version 5.0.14
dotnet add package EntityFrameworkCore.FSharp --version 5.0.3
dotnet add package Microsoft.EntityFrameworkCore.Design --version 5.0.14
dotnet add package Microsoft.EntityFrameworkCore.Sqlite --version 5.0.14
dotnet add package Microsoft.EntityFrameworkCore.Tools --version 5.0.14

Of course, there is support for other databases. For instance, Microsoft.EntityFrameworkCore.SqlServer is for SQL Server, and Npgsql.EntityFrameworkCore.PostgreSQL supports PostgreSQL.

Creating an Identity Database with Entity Framework Core

Executive summary of this section:

  1. Create a file named something like ApplicationDbContext.fs with the following content (and add it to your fsproj file):
module MyDbContext
open Microsoft.AspNetCore.Identity
open Microsoft.AspNetCore.Identity.EntityFrameworkCore
open Microsoft.EntityFrameworkCore
open Microsoft.EntityFrameworkCore.Sqlite // replace with DB of your choice
open Microsoft.EntityFrameworkCore.Design // for IDesignTimeDbContextFactory

type ApplicationDbContext(options : DbContextOptions<ApplicationDbContext>) = 
    inherit IdentityDbContext(options)

    // OPTIONAL. seed the database with some initial roles. 
    override __.OnModelCreating (modelBuilder : ModelBuilder) =
        base.OnModelCreating(modelBuilder)
        modelBuilder.Entity<IdentityRole>().HasData(
            [|
                IdentityRole(Name = "admin", NormalizedName = "ADMIN")
                IdentityRole(Name = "user", NormalizedName = "USER")
            |]) |> ignore

type ApplicationDbContextFactory() =
    interface IDesignTimeDbContextFactory<ApplicationDbContext> with
        member __.CreateDbContext (args: string[]) =
            let optionsBuilder = new DbContextOptionsBuilder<ApplicationDbContext>()
            optionsBuilder.UseSqlite("Data Source=identity.db") |> ignore
            new ApplicationDbContext(optionsBuilder.Options)
  1. Create an initial migration by running dotnet ef migrations add Initial. Add the generated files to your fsproj as well.
  2. Deploy the database with dotnet ef database update.

For those with previous EF Core experience in C#

The above should all seem familiar to you. A few things to note are:

  1. Unlike in C#, F# source files are required to be explicitly listed in fsproj, so don't forget to add the migrations after creation.
  2. The ApplicationDbContextFactory class is necessary to tell the EF Core migration tool how to build a database during 'design time' (when you run dotnet ef). For C# ASP.NET Core apps, EF Core looks for a method called CreateHostBuilder in the Public class. Giraffe lacks this class, so you need to be more explicit. More details here.

That's it. You can skip to the next section.

For those not familiar with EF Core

EF Core is basically two things: an Object Relational Mapper (ORM) and a migration tool. The basic workflow when working with EF Core is:

  1. Define some classes representing the entities in your domain.
  2. Create a class that derives from DbContext. Connect this context class with your domain classes through accessors.
  3. Run dotnet ef migrations add SomeMigrationName to create a "migration". Migrations are source files (in whichever language you are using) that contain instructions to create the database tables and their relationships (foreign key constraints, etc).
  4. Deploy those migrations with dotnet ef database update.
  5. Repeat steps 1-4 every time you make a change to your domain classes.

When we use Identity with EF Core, though, we do not need to design our own classes to represent entities like Users and Roles. Instead, we use IdentityDbContext from Microsoft.AspNetCore.Identity.EntityFrameworkCore, itself a class derived from DbContext, which is already hooked up to all the classes you'll need.

Thus, all you really need for steps 1 and 2 above are:

module MyDbContext
open Microsoft.AspNetCore.Identity.EntityFrameworkCore
open Microsoft.EntityFrameworkCore

type ApplicationDbContext(options : DbContextOptions<ApplicationDbContext>) = 
    inherit IdentityDbContext(options)

Note that IdentityDbContext is actually an alias for IdentityDbContext<IdentityUser,IdentityRole,string> (don't worry about the 'string' parameter). IdentityUser and IdentityRole are the built-in classes which should be adequate for the majority of applications. If you really want, you can extend these and inherit from IdentityDbContext<YourUser, YourRole> instead.

If you want to seed the database with some data, you can override the OnModelCreating method. See this article for details:

override __.OnModelCreating (modelBuilder : ModelBuilder) =
    base.OnModelCreating(modelBuilder)

    // create some roles
    modelBuilder.Entity<IdentityRole>().HasData(
        [|
            IdentityRole(Name = "admin", NormalizedName = "ADMIN")
            IdentityRole(Name = "user", NormalizedName = "USER")
        |]) |> ignore

If we were working with a C# ASP.NET Core application, we'd be done here. However, with F# and Giraffe, we need to add a 'factory' class to help the EF Core migration tool construct an instance of your db context at 'design time' (i.e., when you run dotnet ef migrations). We need to do this because, for ASP.NET Core apps, the tool usually looks for a static method called CreateHostBuilder in the Program class. However, Giraffe does not use such a class, thus a factory is needed. See more here

Below is all you need:

open Microsoft.EntityFrameworkCore.Design // for IDesignTimeDbContextFactory
open Microsoft.EntityFrameworkCore.Sqlite // adds UseSqlite method
// or, you can use other databases
// open Microsoft.EntityFrameworkCore.SqlServer // UseSqlServer
// open Npgsql.EntityFrameworkCore.PostgreSQL // UseNpgsql 

type ApplicationDbContextFactory() =
    interface IDesignTimeDbContextFactory<ApplicationDbContext> with

        member __.CreateDbContext (args: string[]) =
            let optionsBuilder = new DbContextOptionsBuilder<ApplicationDbContext>()
            optionsBuilder.UseSqlite("Data Source=identity.db") |> ignore
            // for other databases::
            // optionsBuilder.UseNpgsql(connectionString) |> ignore
            // optionsBuilder.UseSqlServer(connectionString) |> ignore
            new ApplicationDbContext(optionsBuilder.Options)

Now, we can create a 'migration' with dotnet ef migrations add Initial. If all goes well, you'll have a new directory called 'Migrations`. Add these files to your fsproj file:

<ItemGroup>
  <Compile Include="MyDbContext.fs" />
  <Compile Include="Migrations/ApplicationDbContextModelSnapshot.fs" />
  <Compile Include="Migrations/20220213001824_Initial.fs" /> <!-- or whatever the files are called -->
  <!-- alternatively, you can use a wildcard so you don't have to update this file every migration:
  <Compile Include="Migrations/*.fs" /> 
  -->
  <Compile Include="Program.fs" />
</ItemGroup>

And run dotnet ef database update to deploy the database. If you used SQLite as shown above, you will find a new SQLite file. Below is a snippet of the schema (run sqlite3 identity.db .schema to see the whole thing):

CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" (
    "MigrationId" TEXT NOT NULL CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY,
    "ProductVersion" TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS "AspNetRoles" (
    "Id" TEXT NOT NULL CONSTRAINT "PK_AspNetRoles" PRIMARY KEY,
    "Name" TEXT NULL,
    "NormalizedName" TEXT NULL,
    "ConcurrencyStamp" TEXT NULL
);
CREATE TABLE IF NOT EXISTS "AspNetUsers" (
    "Id" TEXT NOT NULL CONSTRAINT "PK_AspNetUsers" PRIMARY KEY,
    "UserName" TEXT NULL,
    "NormalizedUserName" TEXT NULL,
    "Email" TEXT NULL,
    "NormalizedEmail" TEXT NULL,
    "EmailConfirmed" INTEGER NOT NULL,
    "PasswordHash" TEXT NULL,
    "SecurityStamp" TEXT NULL,
    "ConcurrencyStamp" TEXT NULL,
    "PhoneNumber" TEXT NULL,
    "PhoneNumberConfirmed" INTEGER NOT NULL,
    "TwoFactorEnabled" INTEGER NOT NULL,
    "LockoutEnd" TEXT NULL,
    "LockoutEnabled" INTEGER NOT NULL,
    "AccessFailedCount" INTEGER NOT NULL
);

-- and so much more

That's it! Now we're ready to use this thing!

Configuring Identity and EF Core in Giraffe

So far, we have not done anything related to Giraffe. To begin, open some namespaces:

open Microsoft.AspNetCore.Identity
open Microsoft.EntityFrameworkCore
open Microsoft.EntityFrameworkCore.Sqlite // or whichever database you choose

Then, we need to inject some services in the configureServices function. (If you don't know what that means, see Part 1 of this blog series.) We use AddDbContext to tell ASP.NET Core that we are using EF Core database context we previously created, and specify connection string with the options builder. (We are specifying the connection string again here because this is what is used at runtime -- the previous is used only for creating the migrations.)

services.AddDbContext<MyDbContext.ApplicationDbContext>(fun options ->  
    // replace with method and connection string for your database. 
    options.UseSqlite("Filename=identity.db") |> ignore
    ) |> ignore

Then, we use AddIdentity to configure Identity with a bunch of authentication related options and connect it to EF Core with AddEntityFrameworkStores.

// IdentityUser, IdentityRole from MS.ASP.Identity
services.AddIdentity<IdentityUser, IdentityRole>(fun options -> 
        options.Password.RequireLowercase <- true
        options.Password.RequireUppercase <- true
        options.Password.RequireDigit <- true
        options.Lockout.MaxFailedAccessAttempts <- 5
        options.Lockout.DefaultLockoutTimeSpan <- TimeSpan.FromMinutes(15)
        options.User.RequireUniqueEmail <- true
        // enable this if we use email verification 
        // options.SignIn.RequireConfirmedEmail <- true;
        )
    // tell asp.net identity to use the above store
    .AddEntityFrameworkStores<MyDbContext.ApplicationDbContext>()
    .AddDefaultTokenProviders() // need for email verification token generation
    |> ignore

In the snippet above, notice that we first do AddIdentity<IdentityUser, IdentityRole>. As mentioned, you can derive IdentityUser and IdentityRole if you'd like.

The options builder for AddIdentity, IdentityOptions, contains a lot of goodies, including Password requirements, email verification requirements, and brute force protection. Honestly, this functionality is why you use Identity instead of rolling your own! The example shows some options to get you started.

Lastly, we AddDefaultTokenProviders so we can generate email verification tokens (as well as other tokens) later.

Creating a Registration Page

Now, we are finally ready to start using Identity in our handlers.

For those unfamiliar with Identity: Identity is a comprehensive API supporting authentication UIs. Given a data store (in our case managed by EF Core, though you can implement your own as well), it handles all the difficult tasks like hashing password securely, setting authentication cookies, and so on.

We will mainly be interacting with two components of Identity: UserManager and SignInManager. These were injected into our app when we did AddIdentity above.

But first, let's do some Giraffe.ViewEngine to build the views. Below are a simple registration page and a 'thank you for registering' page to show afterwards. Note that we accept a string list to display any errors that may have occured with previous login attempts. These are called "flash messages" in other languages. We display such messages in red.

let registerPage (errors : string list) = 
    [
        h1 [] [str "Please Register"]
        form [_method "post"] [
            input [_type "text"; _placeholder "email"; _name "email"] 
            input [_type "password"; _placeholder "password"; _name "password"] 
            input [_type "submit"; _value "Register"] 
        ]
        a [_href "/Account/Login"] [str "Already registered?"]
        ul [] (errors |> List.map (fun err -> li [] [str err]))
    ] |> layout

let thanksForRegisteringPage = 
    [
        p [] [ str "thanks for registering, we'll send you a confirmation email soon!" ]
        a [_href "/Account/Login"] [str "go back to login page"]
    ] |> layout

Next we need a corresponding POST handler to handle form submissions. A few things to note in the snippet below:

[<CLIMutable>]
type RegisterModel = 
    {
        // it's okay this is capitalized. 
        Email : string
        Password : string
    }

let registerHandler : HttpHandler =
    fun next ctx -> 
        task {
            let userManager = ctx.GetService<UserManager<IdentityUser>>()
            let! form = ctx.TryBindFormAsync<RegisterModel>()
            match form with
            | Error _ -> 
                return! htmlView (Views.registerPage ["Something went wrong, please try again"]) next ctx
            | Ok form -> 
                let user = IdentityUser(Email = form.Email, UserName = form.Email)

                let! result = userManager.CreateAsync(user, form.Password)

                if result.Succeeded then
                    return! redirectTo false "/Account/RegisterThanks" next ctx
                else
                    // result.Errors contains stuff like:
                    //  Passwords must have at least one non alphanumeric character.
                    //  Passwords must have at least one digit ('0'-'9').
                    //  Passwords must have at least one uppercase ('A'-'Z').
                    let errors = result.Errors |> Seq.map (fun e -> e.Description)  |> List.ofSeq
                    return! htmlView (Views.registerPage errors) next ctx
        }

Finally, we compose a simple router. Note how we are calling Views.registerPage with an empty list to indicate no errors.

let webApp =
    choose [
        GET >=> route "/Account/Register" >=> htmlView (Views.registerPage [])
        POST >=> route "/Account/Register" >=> registerHandler
        setStatusCode 404 >=> text "Not Found" ]

Antiforgery

Actually, the registration page above has a major vulnerability: it is susceptible to the Cross-Site Request Forgery (CSRF) attack. The ASP.NET Core-way of mitigating this attack is to:

  1. For every request, set a cookie called .AspNetCore.Antiforgery.SomeRandomString with a unique token.
  2. Require that all form submissions carry this secret token.

Razor projects (in C#) handle this by automatically setting the cookie on every request, and injecting the token as a hidden field into every form. With Giraffe.ViewEngine, we need to do this manually. Fortunately, this is straightforward using the Giraffe.AntiForgery package, which is a thin wrapper around Microsoft.AspNetCore.Antiforgery. Note At the time of writing, this package is out of date. Please see this gist which is a very slightly modified version that you can add directly to your project.

We need to make three changes to our code to use this package correctly:

These changes are illustrated below:

let registerPage (errors : string list) (token : AntiforgeryTokenSet) =  // token should be last parameter
    [
        h1 [] [str "Please Register"]
        form [_method "post"] [
            input [_type "text"; _placeholder "email"; _name "email"] 
            input [_type "password"; _placeholder "password"; _name "password"] 
            input [_type "submit"; _value "register"] 
            antiforgeryInput token // Add anywhere in form
        ]
        a [_href "/Account/Login"] [str "Already registered?"]
        ul [] (errors |> List.map (fun err -> li [] [str err]))
    ] |> layout
let webApp =
    choose [
        GET >=> route "/Account/Register" >=> csrfHtmlView (Views.registerPage [])
        POST 
        >=> requiresCsrfToken (setStatusCode 403 >=> text "Forbidden") 
        >=> route "/Account/Register" 
        >=> registerHandler
        setStatusCode 404 >=> text "Not Found" ]

When rendering from within a handler, we might do something like:

return! csrfHtmlView (Views.registerPage ["Something went wrong, please try again"]) next ctx

Creating a Login Page

The view for login looks very similar to the registration view. The only difference is that we accept a single boolean for flashing error messages, instead of a string array.

let loginPage (justFailed : bool) (token : AntiforgeryTokenSet) = 
    [
        yield h1 [] [str "Please Login"]
        yield form [_method "post"] [
            input [_type "text"; _placeholder "email"; _name "email"] 
            input [_type "password"; _placeholder "password"; _name "password"] 
            input [_type "submit"; _value "login"] 
            antiforgeryInput token
        ]
        if justFailed then 
            yield p [ _style "color: Red;" ] [ str "Login failed." ]
        yield a [_href "/Account/Register"] [str "Register"]
    ] |> layout

Unlike for registration, logins are handled by SignInManager, similarly available via ctx.GetService. We'll use its PasswordSignInAsync function. If successful, this function sets an autheticated session cookie.

let loginHandler : HttpHandler =
    fun next ctx -> 
        task {
            let! model = ctx.TryBindFormAsync<LoginModel>()
            match model with
            | Ok model -> 
                let signInManager = ctx.GetService<SignInManager<IdentityUser>>()
                let! result = signInManager.PasswordSignInAsync(model.Email, model.Password, true, false)
                match result.Succeeded with
                | true  -> return! redirectTo false "/User" next ctx
                | false -> return! csrfHtmlView (Views.loginPage true) next ctx
            | Error _ -> return! csrfHtmlView (Views.loginPage true) next ctx
        }

Logging out

SignInManager also, obviously, handles logging out. (Logging out does not require a view.)

let logoutHandler : HttpHandler =
    fun next ctx -> 
        task {
            let signInManager = ctx.GetService<SignInManager<IdentityUser>>()
            do! signInManager.SignOutAsync()
            return! redirectTo false "/Account/Login" next ctx
        }

At this point, our routes should look something like:

let webApp =
    choose [
        GET >=> 
            choose [
                route "/Account/Register"       >=> csrfHtmlView (Views.registerPage [])
                route "/Account/Login"          >=> csrfHtmlView (Views.loginPage false)
                route "/Account/RegisterThanks" >=> htmlView Views.thanksForRegisteringPage
            ]
        POST >=> requiresCsrfToken (setStatusCode 403 >=> text "Forbidden") >=> 
            choose [
                route "/Account/Register" >=> registerHandler
                route "/Account/Login" >=> loginHandler
                route "/Account/Logout" >=> logoutHandler
            ]
        setStatusCode 404 >=> text "Not Found" ]

User Profile Page

Below is an example of how to extract information out of IdentityUser:

let userPage (user : IdentityUser) (token : AntiforgeryTokenSet) =
    [
        p [] [
            sprintf "User name: %s, Email: %s" user.UserName user.Email
            |> str
        ]
        form [_method "post"; _action "/Account/Logout"] [
            input [_type "submit"; _value "Logout"] 
            antiforgeryInput token
        ]
    ] |> layout

let userPageHandler : HttpHandler = 
    fun next ctx -> 
        task {
            let userManager = ctx.GetService<UserManager<IdentityUser>>()
            let! user = userManager.GetUserAsync ctx.User
            return! (user |> Views.userPage |> csrfHtmlView) next ctx
        }

We can use the requiresAuthentication function provided by Giraffe in our route to protect this page. Our complete webapp router should look like:

let webApp =
    choose [
        GET >=> 
            choose [
                route "/Account/Register"       >=> csrfHtmlView (Views.registerPage [])
                route "/Account/Login"          >=> csrfHtmlView (Views.loginPage false)
                route "/Account/RegisterThanks" >=> htmlView Views.thanksForRegisteringPage
                route "/Account/Verify"         >=> emailVerificationHandler

                requiresAuthentication (redirectTo false "/Account/Login") 
                >=> route "/User" 
                >=> userPageHandler
            ]
        POST >=> requiresCsrfToken (setStatusCode 403 >=> text "Forbidden") >=> 
            choose [
                route "/Account/Register" >=> registerHandler
                route "/Account/Login"    >=> loginHandler
                route "/Account/Logout"   >=> logoutHandler
            ]
        setStatusCode 404 >=> text "Not Found" ]

Handling Email Verification

To explore some additional functionality in Identity, let's see how easy it is to add email-verification to our site. To do this, We merely need to call userManager.GenerateEmailConfirmationTokenAsync(user) after we successfully create the user. This generates a signed token which can be redeemed with userManager.ConfirmEmailAsync later.

Also remember that there is an option options.SignIn.RequireConfirmedEmail which you can enable when configuring the Identity service.

Note that, below, we are not actually sending the email, but instead merely printing it to the console to simulate the process. In practice, you should probably use something like the System.Net.Mail SMTP client to talk to your mail server, or use a API-based service such as Twilio.

let registerHandler : HttpHandler =
    fun next ctx -> 
        task {
            let userManager = ctx.GetService<UserManager<IdentityUser>>()
            let! form = ctx.TryBindFormAsync<RegisterModel>()
            match form with
            | Error _ -> 
                return! csrfHtmlView (Views.registerPage ["Something went wrong, please try again"]) next ctx
            | Ok form -> 
                let user = IdentityUser(Email = form.Email, UserName = form.Email)

                let! result = userManager.CreateAsync(user, form.Password)

                if result.Succeeded then
            
                    // NEW STUFF HERE //
                    let! token = userManager.GenerateEmailConfirmationTokenAsync(user)
                    let baseUrl = $"{ctx.Request.Scheme}://{ctx.Request.Host}/Account/Verify"
                    let url = QueryHelpers.AddQueryString(baseUrl, dict["UserId", user.Id ; "Token", token])

                    // !!! 
                    // This is where you would send an email to the user, with something like System.Net.Mail or similar
                    // However, for this sample we're just going to print it to the console so you can visit the site manually
                    // !!!
                    printfn "***********\nYour verification url is: %s\n***********" url

                    return! redirectTo false "/Account/RegisterThanks" next ctx
                else
                    let errors = result.Errors |> Seq.map (fun e -> e.Description)  |> List.ofSeq
                    return! csrfHtmlView (Views.registerPage errors) next ctx
        }

When the user visits this url, you can process it with the userManager.ConfirmEmailAsync method:

let emailVerificationHandler : HttpHandler = 
    fun next ctx -> 
        task {
            let userManager = ctx.GetService<UserManager<IdentityUser>>()
            match ctx.TryBindQueryString<EmailVerificationModel>() with
            | Ok qs -> 
                let! user = userManager.FindByIdAsync(qs.UserId)

                if not (isNull user) then

                    // Interesting part:
                    let! result = userManager.ConfirmEmailAsync(user, qs.Token);
                    return! (Views.emailVerificationPage result.Succeeded |> htmlView) next ctx

                else
                    return! (Views.emailVerificationPage false |> htmlView) next ctx
            | Error s -> 
                return! (Views.emailVerificationPage false |> htmlView) next ctx
        }

UserManager also has a GeneratePasswordResetTokenAsync method for generating password reset tokens, which can be used in the same way as above. It even has methods like GenerateTwoFactorTokenAsync for setting up two factor authentication and other more advanced functionality.

Authorization

If you want finer grained control over resources than just checking for authentication, you can use Claims, Role, or Policy-based Authorization. Fortunately, such authorization can be done in the exact way as we did for JWT authorization, so we won't repeat it here.

Adding Claims and User Roles

Adding claims to users, and users to roles, can be accomplished through the userManager.AddToRoleAsync and userManager.AddClaimsAsync, respectively. For example, you can modify the registerHandler function to add a role and some claims when the user is first registered:

let user = IdentityUser(Email = form.Email, UserName = form.Email)

let! result = userManager.CreateAsync(user, form.Password)

if result.Succeeded then
    let! res1 = userManager.AddToRoleAsync(user, "USER") 

    let! res2 = userManager.AddClaimsAsync(user, [ 
        Claim("tier", "free") // change this to "paid" after they subscribe, for example
    ])

    // (You should check that these result vars are actually valid in prod code, obviously...)

    // ...email verification code omitted...

    return! redirectTo false "/Account/RegisterThanks" next ctx
else
    let errors = result.Errors |> Seq.map (fun e -> e.Description)  |> List.ofSeq
    return! csrfHtmlView (Views.registerPage errors) next ctx

Adapting for SPA's

This article built a login system for a 'traditional' multi-page web application, based on cookies. In contrast, the general trend amongst SPA developers seems to be to outsource authentication to a third party OAuth server, and use JWT to talk to the back API server.

However, under certain conditions, you can still use Cookie-based authentication (and thus much of the code and information in this article). In fact, I think this method is far less complicated then dealing with Oauth, and is my preferred method.

The conditions, stolen from this Auth0 article, are:

  1. The client code must be served by the backend API server,
  2. The client must have the same domain as the backend, and
  3. The client must only make AJAX calls to that backend.

Such a SPA could be treated almost identically to a multi-page application. The CSRF token would be retrieved once during startup and be reused throughout the life of the application.

Credits

Comments, Questions?

Feel free to reach out! My email is toshATcarpenoctemDOTdev. My twitter is @carpenoctemdev.