# ADR: Identity and the session token, explained for a new developer

Status: accepted, 2026-09-03. The Operating Contract asks for a
junior-developer record for any foundational configuration this project
touches, and ADR: Accounts and per-user bids touched the composition root, the
request pipeline and the database schema in one change. That record says what
was decided. This one says what the words mean.

Read it beside `api/TheYard.Api/Program.cs` and `api/TheYard.Api/Tokens.cs`.
The samples below are those files, read from this build.

## In plain words

This page explains, for a developer new to ASP.NET Core, how the site knows who is calling it. Accounts and password hashes live in a library (ASP.NET Core Identity), and a signed proof of who you are (a token) travels in a cookie the page's scripts cannot read.

What that is worth: a developer can add a protected endpoint or a user field by following the page's own checklist, and the organization's sign-in code can be maintained by its newest hire, with the signing key kept out of the repository.

## Context

Before this change the API had no idea who was calling it. There was one set of
bids in memory, and "you are the high bidder" meant "the only person in the
room is". Adding accounts meant answering four questions that a newcomer to
ASP.NET Core will meet in that order:

1. Where do usernames and password hashes live, and who writes that code?
2. Once somebody proves who they are, how does the next request know?
3. How does the server read that proof back without trusting the browser?
4. How does an endpoint say "not for strangers"?

The answers are Identity, a signed token, a cookie the page cannot read, and
`RequireAuthorization()`. Each gets a section.

## 1. What Identity is

ASP.NET Core Identity is two things wearing one name.

The first is a **schema**: seven tables, all prefixed `AspNet`, holding users,
their password hashes, their roles, their external logins and their claims. You
can see them created in
`api/TheYard.Infrastructure/Migrations/20260903084932_AccountsAndPerUserBids.cs`.
This project uses one of the seven. The other six are along for the ride
because the store that manages the first one knows how to manage all of them.

The second is a **service**, `UserManager<YardUser>`, which is the only thing
the endpoints actually call. It hashes passwords with PBKDF2 and a per-user
salt, it normalises email addresses so that `Steve@Example.com` and
`steve@example.com` cannot both register, and it enforces the password rules
configured beside it. None of that is hard to describe and all of it is easy to
get subtly wrong, which is the argument for not writing it here.

`YardUser` is the project's own user row. It inherits everything Identity needs
and adds one column:

```csharp
public sealed class YardUser : IdentityUser
{
    public long CreatedAtMs { get; set; }
}
```

That is the whole customisation. Inheriting from `IdentityUser` rather than
copying its fields is what lets `AddEntityFrameworkStores<YardDbContext>` find
the columns it expects.

### AddIdentityCore versus AddIdentity

This distinction traps people, so here it is plainly.

`AddIdentity<TUser, TRole>()` registers the user store **and** Identity's own
cookie authentication scheme, its sign-in manager, its redirect-to-login-page
behaviour, and a set of cookies named `.AspNetCore.Identity.Application`. It is
built for a server-rendered site with login pages.

`AddIdentityCore<TUser>()` registers the user store and stops. No
authentication scheme, no redirects, no second cookie.

This API is a JSON service behind a React page, and it already has an
authentication scheme, described in section 3. Calling `AddIdentity` here would
install a second, competing one, and the symptom would be a 302 redirect to a
login page that does not exist where a 401 was expected. `AddIdentityCore` is
the one line that avoids all of that.

```csharp
/// <summary>Reads or invents the signing key and registers the session issuer.</summary>
public static void AddTheYardSessions(this WebApplicationBuilder builder, YardComposition host)
{
    // Accounts (ADR: Accounts and per-user bids). Identity owns the password
    // hashing, the normalised lookups and the account tables, which is the part
    // worth not writing twice; the session is a JWT this service signs and reads
    // itself, carried in a cookie the page cannot touch.
    //
    // The signing key is configuration. The deploy hands both containers the same
    // one from a repository secret (Auth__SigningKey in the container spec, filled
    // at roll time like the connection strings), so a session survives a roll and
    // a token minted by one container reads on the other. Without a usable one,
    // which is what a developer's machine, a test and a roll whose substitution
    // failed all have, the process invents a random key and says so: every session
    // ends with the process, and no key is ever committed (ADR: Three readers with
    // no memory of the project).
    string? configuredSigningKey = TokenIssuer.ConfiguredKey(builder.Configuration["Auth:SigningKey"]);
    string signingKey = configuredSigningKey ?? Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
    // A year, renewed on every day it is used (Tokens.cs, the renewal region):
    // a login lasts a year past the last visit, which is what "permanent" means
    // for a cookie that has to expire somewhere (ADR: Accounts and per-user
    // bids, addendum). Configuration, so a test can shorten it.
    var tokens = new TokenIssuer(signingKey, TimeSpan.FromDays(builder.Configuration.GetValue("Auth:SessionDays", 365)));
    builder.Services.AddSingleton(tokens);
    host.ConfiguredSigningKey = configuredSigningKey;
    host.SigningKey = signingKey;
    host.Tokens = tokens;
}

/// <summary>Registers the registration allowance, Identity over the request's store, and the cookie-borne JWT.</summary>
public static void AddTheYardAccounts(this WebApplicationBuilder builder, YardComposition host)
{
    var tokens = host.Tokens;
    // The hour's allowance of new accounts, for the whole site (ADR: The one write
    // a stranger can make). Configurable because the tests need to reach it, and
    // because a number that cannot be changed without a deploy is a number nobody
    // tunes.
    builder.Services.AddSingleton(new RegistrationLimit(
        builder.Configuration.GetValue("Accounts:RegistrationsPerHour", RegistrationLimit.DefaultPerHour),
        () => DateTimeOffset.UtcNow));

    // Identity is registered whether or not a store came up, because the store
    // is now a per-request choice: the same UserManager serves the relational
    // accounts on one request and the document accounts on the next, and a
    // request on a backend that has no accounts is refused by the endpoint before
    // it asks for one (ADR: One container, both stores).
    builder.Services
        .AddIdentityCore<YardUser>(options =>
        {
            // Long over ornate. A length requirement is the only one of these
            // that measurably helps, and the rest mostly teach people to write
            // the password down (NIST 800-63B says so at more length).
            options.Password.RequiredLength = 8;
            options.Password.RequireNonAlphanumeric = false;
            options.Password.RequireUppercase = false;
            options.Password.RequireDigit = false;
            options.User.RequireUniqueEmail = true;
            // #region lockout
            // Five wrong passwords buys five minutes off.
            //
            // Without this, POST /api/auth/login
            // is an unmetered password oracle against real accounts: the endpoint
            // is public, there is no throttle in front of it, and every attempt
            // costs an attacker one request. Five and five is the usual shape and
            // the reason it works is arithmetic rather than strength: it turns
            // thousands of guesses a minute into twelve an hour, per account,
            // which is the difference between a wordlist finishing and not.
            //
            // The refusal after a lockout says the same sentence as a wrong
            // password, deliberately. A distinct "this account is locked" is a
            // reply that confirms the address is registered here, and the login
            // endpoint already goes out of its way not to be that
            // (ADR: A password guess should cost something).
            options.Lockout.MaxFailedAccessAttempts = 5;
            options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5);
            options.Lockout.AllowedForNewUsers = true;
            // #endregion lockout
        });
    // #region user-store-per-request
    // The store behind UserManager, chosen by the request: Identity's own tables
    // on the relational backend, one document per account on the document one
    // (ADR: Accounts on a document store). One of the four scoped registrations
    // in the application, and the reason the per-request context exists.
    builder.Services.AddScoped<IUserStore<YardUser>>(services =>
        services.GetRequiredService<CurrentBackend>().Backend.UserStore(services)
            ?? throw new InvalidOperationException("this request's store keeps no accounts; RequestAccounts answers null before this is reached"));

    // The handlers take RequestAccounts rather than UserManager, because UserManager is built
    // over the request's store, and a store that did not come up has no accounts to build it
    // over. RequestAccounts asks first and answers null, so no handler meets the throw above.
    builder.Services.AddScoped(services => new RequestAccounts(() =>
        services.GetRequiredService<CurrentBackend>().Backend.Ready
            ? services.GetRequiredService<UserManager<YardUser>>()
            : null));
    // #endregion user-store-per-request
    builder.Services.AddSingleton<PasswordReset>();

    builder.Services
        .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
        .AddJwtBearer(options =>
        {
            options.TokenValidationParameters = tokens.Validation;
            options.Events = new JwtBearerEvents
            {
                // The token arrives in a cookie rather than an Authorization
                // header, because a page that can read its own token can leak it.
                OnMessageReceived = context =>
                {
                    if (context.Request.Cookies.TryGetValue(TokenIssuer.CookieName, out string? cookie))
                    {
                        context.Token = cookie;
                    }
                    return Task.CompletedTask;
                },
            };
        });
    builder.Services.AddAuthorization();
}
```
*Live from [`api/TheYard.Api/Composition/AuthRegistration.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Api/Composition/AuthRegistration.cs#L17-L134), region `auth`, read from this build at 1d6b50b.*

### Why a scoped DbContext sits beside the factory

ADR: The relational store registered `AddDbContextFactory<YardDbContext>`,
which hands out a fresh context on request rather than registering one.
Everything in this application is a singleton and loads its data once, so a
factory was the right shape.

Identity's Entity Framework store does not use a factory. It asks the container
for a `YardDbContext` directly, once per request, because that is the pattern
every ASP.NET Core sample uses. So the composition root adds one adapter
between the two:

```csharp
builder.Services.AddScoped(services =>
    services.GetRequiredService<IDbContextFactory<YardDbContext>>().CreateDbContext());
```

"Scoped" means one instance per HTTP request, disposed when the request ends.
That matters because a `DbContext` is not thread-safe and holds a change
tracker, so sharing one across requests would let two callers see each other's
half-finished work. This registration is the only scoped one in the
application, and the comment above it in `Program.cs` says why it exists so
that nobody deletes it as an oddity.

## 2. What signing a token means

A JWT is three base64 chunks joined by dots: a header saying which algorithm
was used, a payload of claims, and a signature. Two things about it surprise
people.

**It is not encrypted.** Anyone holding the token can read the claims. Paste
one into any JWT decoder and the email address is right there. That is why
`Issue` puts the account id and the email in it and nothing else: everything in
the payload is disclosed to whoever gets hold of it, and everything else the
application needs it can look up.

**The signature is what makes it trustworthy.** HMAC-SHA256 takes the header,
the payload and a secret key, and produces a fixed-length value. Change one
character of the payload and the value no longer matches. Since only the server
knows the key, only the server can produce a signature that validates, so a
token that validates is one this server issued. Forging one means guessing 32
random bytes.

```csharp
/// <summary>
/// The claims are the user's id, the name to greet them by, and the store
/// the account lives in, and nothing else. A token is sent on every
/// request and is readable by anyone holding it, so anything in here is
/// both bandwidth and disclosure; everything the application needs beyond
/// identity it can look up.
/// </summary>
public string Issue(string userId, string email, string store) => Issue(userId, email, store, _lifetime);

/// <summary>The same token with a lifetime of the caller's choosing; the tests use it to make a token that is already a few days old.</summary>
public string Issue(string userId, string email, string store, TimeSpan lifetime)
{
    var handler = new JsonWebTokenHandler();
    return handler.CreateToken(new SecurityTokenDescriptor
    {
        Issuer = Issuer,
        Audience = Audience,
        Subject = new ClaimsIdentity(
        [
            new Claim(ClaimTypes.NameIdentifier, userId),
            new Claim(ClaimTypes.Name, email),
            new Claim(StoreClaim, store),
        ]),
        Expires = DateTime.UtcNow.Add(lifetime),
        SigningCredentials = _credentials,
    });
}

// #region reset-tokens
/// <summary>
/// A password reset link's token: the same signature as a session, a
/// different audience so the session pipeline refuses it, an hour of
/// life, and a fingerprint of the password hash it was minted against so
/// it dies the moment the password changes (ADR: Accounts and per-user
/// bids, addendum). No table of tokens to keep, no token provider that
/// needs a key ring the container does not have.
/// </summary>
private const string ResetAudience = "theyard-reset";
public static readonly TimeSpan ResetLifetime = TimeSpan.FromHours(1);

public string IssueReset(string userId, string store, string? passwordHash)
{
    var handler = new JsonWebTokenHandler();
    return handler.CreateToken(new SecurityTokenDescriptor
    {
        Issuer = Issuer,
        Audience = ResetAudience,
        Subject = new ClaimsIdentity(
        [
            new Claim(ClaimTypes.NameIdentifier, userId),
            new Claim(StoreClaim, store),
            new Claim("fingerprint", Fingerprint(passwordHash)),
        ]),
        Expires = DateTime.UtcNow.Add(ResetLifetime),
        SigningCredentials = _credentials,
    });
}

/// <summary>The claims of a valid, unexpired reset token, or null for anything else, a session token included.</summary>
public async Task<(string UserId, string Store, string Fingerprint)?> ReadResetAsync(string? token)
{
    if (string.IsNullOrWhiteSpace(token))
    {
        return null;
    }

    var handler = new JsonWebTokenHandler();
    var parameters = Validation.Clone();
    parameters.ValidAudience = ResetAudience;
    var result = await handler.ValidateTokenAsync(token, parameters);
    if (!result.IsValid)
    {
        return null;
    }

    // The handler writes the long claim type as its short name and, on
    // its own, reads it back short; the bearer pipeline maps it long. Both
    // spellings are read so this does not depend on which handler minted it.
    string? id = (result.ClaimsIdentity.FindFirst(ClaimTypes.NameIdentifier) ?? result.ClaimsIdentity.FindFirst("nameid"))?.Value;
    string? store = result.ClaimsIdentity.FindFirst(StoreClaim)?.Value;
    string? fingerprint = result.ClaimsIdentity.FindFirst("fingerprint")?.Value;
    return id is null || store is null || fingerprint is null ? null : (id, store, fingerprint);
}

/// <summary>Sixteen hex characters of the password hash's SHA-256; the hash itself never leaves the store.</summary>
public static string Fingerprint(string? passwordHash) =>
    Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(passwordHash ?? "")).AsSpan(0, 8));
// #endregion reset-tokens

/// <summary>
/// The session cookie's options: HttpOnly, and Secure when the browser reached
/// us over TLS. Behind the edge this process is spoken to over plain HTTP; the
/// forwarded-headers middleware reads X-Forwarded-Proto from the proxies it
/// trusts and sets the request's scheme, so `IsHttps` is true on a request that
/// was HTTPS the whole way to the visitor (ADR: The order of the request pipeline).
/// Until then this read the header itself (ADR: Edge deploy economics).
/// </summary>
/// <param name="context">The request the cookie answers.</param>
/// <param name="lifetime">How long the cookie lives.</param>
public static CookieOptions CookieFor(HttpContext context, TimeSpan lifetime) => new()
{
    HttpOnly = true,
    Secure = context.Request.IsHttps,
    SameSite = SameSiteMode.Lax,
    Path = "/",
    MaxAge = lifetime,
};
```
*Live from [`api/TheYard.Api/Tokens.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Api/Tokens.cs#L119-L225), region `issue`, read from this build at 1d6b50b.*

`ClockSkew` deserves a note. The library defaults to five minutes of slack on
expiry, which is a sensible allowance when two different servers with two
different clocks are involved. Here one process both issues and validates the
token, so five minutes of extra life is slack with no purpose behind it. Thirty
seconds is the setting.

### Where the key comes from

`Auth:SigningKey` is configuration: an environment variable in the container, a
user secret locally, a secret store in a real deployment. When it is absent the
process generates 32 random bytes and logs that it did.

That behaviour is deliberate and its cost is stated out loud: a restart with no
configured key signs everybody out, because the new process cannot validate
tokens the old one signed. The alternative a repository must never take is a
committed default. A signing key in source control is every session forever for
anyone who can read the repository, and it stays true after the file is deleted
because git keeps history.

## 3. Why the cookie is httpOnly

The token proves who you are. Whoever holds it is you, which is the property
that makes bearer tokens convenient and also the one that makes them dangerous.

The common alternative is `localStorage`: the page keeps the token in a
variable and sets an `Authorization` header on each request. That works, and it
means every script running on the page can read the token. Any injected script,
any compromised dependency, any browser extension with page access can take it
and use it from anywhere.

`HttpOnly = true` tells the browser to withhold the cookie from JavaScript
entirely. `document.cookie` does not show it. The page never has the token, so
the page cannot leak it, and the client code gets shorter rather than longer:
`src/api/auth.ts` never touches a token at all.

The other attributes each earn their place:

- **`SameSite = Lax`** stops the cookie from riding along on a cross-site POST,
  which is the shape a CSRF attack takes. Lax rather than Strict so that
  arriving from an external link still finds you signed in.
- **`Secure`** tells the browser to send the cookie over HTTPS only. It is
  computed rather than hardcoded, because behind the edge this process is
  spoken to over plain HTTP even when the visitor's connection was HTTPS the
  whole way. `X-Forwarded-Proto` is the header that carries that fact across
  the hop, and ADR: Edge economics explains the hop.
- **`Path = "/"`** so one cookie covers the API and the page. Deleting a cookie
  means re-sending it with the same path and same-site attributes, which is why
  the logout endpoint calls the same `CookieFor` helper the login endpoint
  does. Get that wrong and the browser keeps a second cookie of the same name
  on a different path, and the user stays signed in after signing out.

## 4. How the server reads it back

`AddJwtBearer` expects the token in an `Authorization: Bearer ...` header. This
project puts it in a cookie, so one event hook redirects the lookup:

```csharp
options.Events = new JwtBearerEvents
{
    OnMessageReceived = context =>
    {
        if (context.Request.Cookies.TryGetValue(TokenIssuer.CookieName, out string? cookie))
        {
            context.Token = cookie;
        }
        return Task.CompletedTask;
    },
};
```

`OnMessageReceived` runs before validation and its job is to answer "where is
the token". Setting `context.Token` says: here, use this. Everything after that
point is the normal path, signature check included, so nothing about validation
has been weakened by moving where the token was found.

Two lines in the pipeline finish the job:

```csharp
app.UseAuthentication();
app.UseAuthorization();
```

Order matters and the names are easy to blur. **Authentication** is "who are
you": it reads the token and builds `HttpContext.User`. **Authorization** is
"may you": it checks that principal against whatever the endpoint requires.
Authentication has to run first, because authorization cannot check a principal
nobody built yet. Put these two after the endpoints and every request arrives
anonymous.

Both run on every request, including the ones that do not require an account.
That is on purpose. A signed-in visitor loading the listing gets a populated
`HttpContext.User`, which is how `GET /api/bids` knows whose badges to draw
without a separate request.

## 5. What RequireAuthorization does

`.RequireAuthorization()` attaches metadata to one endpoint saying a principal
is required. The authorization middleware reads that metadata and, finding no
authenticated user, ends the request with 401 before the handler runs.

Four endpoints carry it: placing a bid, buying now, clearing your bids, and the
bid history. Reading stays open, so an anonymous visitor still watches the
auction and cannot bid in it.

```csharp
// Register, sign in, sign out, and who am I. The token never reaches the page:
// it is set as an httpOnly cookie on the way out and read from the cookie on the
// way back in, so a script on the page cannot read it and cannot be tricked into
// sending it somewhere else (ADR: Accounts and per-user bids).
app.MapPost("/api/auth/register", Register)
    .WithName("Register")
    .WithTags("Accounts")
    .WithSummary("Create an account on this store and sign in")
    .WithDescription("Answers the account and sets the session cookie. A few dozen registrations an hour are allowed "
        + "across all strangers, and a refused password gives the slot back.")
    .ProducesProblem(StatusCodes.Status400BadRequest)
    .ProducesProblem(StatusCodes.Status429TooManyRequests)
    .ProducesProblem(StatusCodes.Status503ServiceUnavailable);

app.MapPost("/api/auth/login", Login)
    .WithName("Login")
    .WithTags("Accounts")
    .WithSummary("Sign in and receive the session cookie")
    .WithDescription("One sentence for every way this can fail, including a locked account, so the reply never says "
        + "which addresses are registered here. Five wrong passwords lock an account for five minutes.")
    .ProducesProblem(StatusCodes.Status400BadRequest)
    .ProducesProblem(StatusCodes.Status401Unauthorized)
    .ProducesProblem(StatusCodes.Status503ServiceUnavailable);

// #region password-reset
// A password reset in two halves (ADR: Accounts and per-user bids, addendum).

// The operator mints a link from the Admin tab, behind the key, for an
// address on this site's store: an hour of life, one use, because the token
// carries a fingerprint of the password hash it was minted against and the
// hash changes when the password does. The visitor opens the link, chooses a
// new password, and is signed in. The same second half will serve an emailed
// link when there is a sender to send it; only who hands the link over
// changes.
app.MapPost("/api/admin/reset-links", OperatorResetLink);

// "Forgot password": the same link, sent by the site instead of handed over
// by the operator. Public, so it answers one sentence whether or not the
// address has an account here, and one email per address per five minutes.
// Without a sender configured it says so and points at the operator.
app.MapPost("/api/auth/forgot", Forgot)
    .WithName("ForgotPassword")
    .WithTags("Accounts")
    .WithSummary("Email a reset link to an address")
    .WithDescription("One sentence back whether or not the address has an account here, and one email per address "
        + "per five minutes. A container with no sender configured says so.")
    .ProducesProblem(StatusCodes.Status400BadRequest)
    .ProducesProblem(StatusCodes.Status503ServiceUnavailable);

app.MapPost("/api/auth/reset", Reset)
    .WithName("ResetPassword")
    .WithTags("Accounts")
    .WithSummary("Use a reset link to choose a new password and sign in")
    .WithDescription("The link works once, for an hour, on the site it was minted for. A refused password leaves the "
        + "old one in place.")
    .ProducesProblem(StatusCodes.Status400BadRequest)
    .ProducesProblem(StatusCodes.Status503ServiceUnavailable);
// #endregion password-reset

app.MapPost("/api/auth/logout", Logout)
    .WithName("Logout")
    .WithTags("Accounts")
    .WithSummary("Sign out")
    .WithDescription("Deletes the session cookie and answers the anonymous account. Never fails.");

app.MapGet("/api/auth/me", WhoAmI)
    .WithName("WhoAmI")
    .WithTags("Accounts")
    .WithSummary("Who the session belongs to on this store")
    .WithDescription("Signed out is an answer, not a 401: a session opened on the other store reads as signed out here.");
```
*Live from [`api/TheYard.Api/Endpoints/AccountEndpoints.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Api/Endpoints/AccountEndpoints.cs#L19-L88), region `auth-endpoints`, read from this build at 1d6b50b.*

Inside an endpoint that requires it, `http.UserId()` reads the account id from
the validated claims:

```csharp
/// <summary>
/// The caller's account id on an endpoint that required one. Throwing here
/// would mean authorization let through a token with no subject, which is
/// not a case to handle gracefully; it is a case to find out about.
/// </summary>
public static string UserId(this HttpContext context) =>
    context.User.FindFirstValue(ClaimTypes.NameIdentifier)
    ?? throw new InvalidOperationException("An authorized request carried no account id");

/// <summary>The caller's account id, or null where signing in is optional.</summary>
public static string? UserIdOrNull(this HttpContext context) =>
    context.User.FindFirstValue(ClaimTypes.NameIdentifier);
```
*Live from [`api/TheYard.Api/Tokens.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Api/Tokens.cs#L233-L244), region `who`, read from this build at 1d6b50b.*

It throws rather than returning null. Reaching that line with no id would mean
authorization admitted a token with no subject, and a graceful fallback there
hides a broken pipeline behind a working-looking page. `UserIdOrNull()` is the
sibling for endpoints where signing in is optional.

## What to change when

- **A new endpoint that changes something:** `.RequireAuthorization()` on the
  map call, `HttpContext` in the parameter list, `http.UserId()` for the owner.
  A test in `api/TheYard.Tests/AuthTests.cs` should assert it answers 401 to a
  caller with no cookie, because that is the assertion that fails when somebody
  copies the endpoint above it and forgets the attribute.
- **A new field on the user:** add the property to `YardUser`, then
  `dotnet ef migrations add <Name> --project api/TheYard.Infrastructure
  --startup-project api/TheYard.Api`. The startup project argument is required
  and ADR: The relational store explains why.
- **A password rule:** the options block inside `AddIdentityCore`. Length is
  the requirement with evidence behind it; the composition rules mostly teach
  people to write the password down, and NIST 800-63B says so at more length.
- **The token's contents:** `TokenIssuer.Issue`. Remember that every claim is
  readable by anyone holding the token and is sent on every request.
- **A real deployment:** set `Auth__SigningKey` in the container's environment
  from a secret store. Nothing else changes, and nothing about the key ever
  enters the repository.

## Where it sits

This walkthrough covers host Api code, AddIdentityCore in Composition/AuthRegistration.cs and TokenIssuer in Tokens.cs, plus YardUser and the Identity tables in Infrastructure. Choosing AddIdentityCore over AddIdentity keeps the host small: the JSON API takes the user store it needs and leaves out a cookie scheme and login redirects it would have to fight. A missing Auth:SigningKey costs a sign-out for everyone on restart, which the deploy now avoids by passing one key to both sites. A system that had to revoke a session mid-flight would choose server-side sessions over a self-contained token.

## Files

- [`api/TheYard.Api/Composition/AuthRegistration.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Composition/AuthRegistration.cs): the composition root's part, Identity and the token registered.
- [`api/TheYard.Api/Composition/RequestPipeline.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Composition/RequestPipeline.cs): the pipeline.
- [`api/TheYard.Api/Endpoints/AccountEndpoints.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Endpoints/AccountEndpoints.cs): the four auth endpoints.
- [`api/TheYard.Api/Tokens.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Tokens.cs): issuing, validating, the cookie attributes, and reading the caller back out.
- [`api/TheYard.Api/Accounts.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/Accounts.cs): the request and response shapes, and the translation of Identity's error codes into a sentence.
- [`api/TheYard.Infrastructure/YardUser.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Infrastructure/YardUser.cs): the user row and its one extra column.
- [`api/TheYard.Infrastructure/YardDbContext.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Infrastructure/YardDbContext.cs): now an `IdentityDbContext`, which is what puts the seven tables in the model.
- [`api/TheYard.Tests/AuthTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/AuthTests.cs): the suite that holds all of this to its claims, including that the token never appears in a response body.
- [`docs/decisions/ADR-037-accounts.md`](https://github.com/SteveStout/TheYard/blob/main/docs/decisions/ADR-037-accounts.md): the decisions this record explains.
- [`docs/decisions/ADR-018-program-cs-explained.md`](https://github.com/SteveStout/TheYard/blob/main/docs/decisions/ADR-018-program-cs-explained.md): the rest of the host file, walked the same way.

## Addendum, 2026-10-03: a second scoped registration

This registration is no longer the only scoped one. `RequestAccounts` (`api/TheYard.Api/RequestAccounts.cs`) is registered per request beside it in `Composition/AuthRegistration.cs`, so the account handlers take it as a parameter instead of reaching into the service container. It asks whether the request's store came up before it opens `UserManager`, which means no handler ever meets the throw in the user store factory.
