Authentication
Wallow uses a sign-in ticket pattern to bridge the React auth app (apps/wallow-auth) with the API's cookie authentication system. The auth UI verifies credentials through a JSON API call, but landing the user in an authenticated session needs a top-level browser navigation. Instead of trying to set the cookie on that background call, the API issues an encrypted, short-lived ticket that the browser exchanges for a real auth cookie via a full-page GET.
Why Tickets Exist
The auth app is a client-side React application. It authenticates by calling the login API over the app's same-origin proxy with fetch/XHR, which returns JSON (containing the ticket) — not an auth cookie.
Completing sign-in requires two things that a background fetch cannot do: set an HttpOnly cookie the browser will actually send on subsequent requests, and resume the paused OIDC authorization at returnUrl. Both need a real top-level navigation. The sign-in ticket lets the app hand off from its fetch-based login to a direct browser GET on the API's exchange-ticket endpoint, which sets the cookie and redirects into returnUrl.
Ticket Lifecycle
apps/wallow-auth (React) Wallow.Api (AccountController)
───────────────────────────── ──────────────────────────────────
POST /v1/identity/auth/login ───► Validate credentials
◄─── Return { signInTicket: "<token>" }
Navigate browser to:
/v1/identity/auth/exchange-ticket
?ticket=<token>
&returnUrl=<url>
&clientId=<id> ───► 1. Decrypt + validate ticket
2. SET NX ticket:used:{jti} in Valkey (90s TTL)
3. If key already existed → reject (replay)
4. SignInAsync → issue browser auth cookie
◄─── 302 Redirect to returnUrl
Creation
CreateSignInTicket runs in AccountController after credentials are successfully verified:
private const string TicketPurpose = "SignInTicket";
private static readonly TimeSpan _ticketLifetime = TimeSpan.FromSeconds(60);
private string CreateSignInTicket(string email, bool rememberMe)
{
ITimeLimitedDataProtector protector = dataProtectionProvider
.CreateProtector(TicketPurpose)
.ToTimeLimitedDataProtector();
SignInTicketPayload payload = new(email, rememberMe, Guid.NewGuid());
string json = JsonSerializer.Serialize(payload);
return protector.Protect(json, _ticketLifetime);
}
private sealed record SignInTicketPayload(string Email, bool RememberMe, Guid Jti);
Jti(JWT ID): aGuid.NewGuid()unique identifier embedded in every ticket. Used as the Valkey key for single-use enforcement.- TTL: 60 seconds. After this window the data protector refuses to decrypt the token.
- Purpose:
"SignInTicket"— ASP.NET Core Data Protection uses the purpose string as part of the encryption key derivation. A ticket encrypted for"SignInTicket"cannot be decrypted by any other protector purpose (e.g.,"ExternalLogin").
Data Protection
ASP.NET Core's ITimeLimitedDataProtector wraps the standard IDataProtector with an expiry timestamp embedded in the ciphertext. The protector:
- Encrypts the JSON payload using AES-256-CBC with HMACSHA256 authentication.
- Encodes the expiry as part of the protected payload.
- Rejects decryption attempts after the expiry has passed (throws
CryptographicException).
The encryption keys are managed by the Data Protection system (stored in the configured key ring — typically a shared volume or Valkey in production). All API instances share the same key ring, so any instance can validate a ticket issued by another.
Exchange and Single-Use Enforcement
ExchangeTicket in AccountController handles the browser's direct GET request:
[HttpGet("exchange-ticket")]
[AllowAnonymous]
public async Task<IActionResult> ExchangeTicket(
[FromQuery] string ticket,
[FromQuery] string? returnUrl,
[FromQuery] string? clientId = null)
{
SignInTicketPayload? payload = ValidateSignInTicket(ticket);
if (payload is null)
{
return BadRequest(new { succeeded = false, error = "invalid_or_expired_ticket" });
}
// Replay prevention: each ticket can only be exchanged once
IDatabase redisDb = redis.GetDatabase();
bool wasSet = await redisDb.StringSetAsync(
$"ticket:used:{payload.Jti}", "1", TimeSpan.FromSeconds(90), false, When.NotExists);
if (!wasSet)
{
return Unauthorized(new { succeeded = false, error = "ticket_already_used" });
}
WallowUser? user = await signInManager.UserManager.FindByEmailAsync(payload.Email);
if (user is null)
{
return BadRequest(new { succeeded = false, error = "invalid_or_expired_ticket" });
}
await signInManager.SignInAsync(user, isPersistent: payload.RememberMe);
// ... redirect
}
The Valkey SET NX EX call is atomic:
NX(Not eXists): only sets the key if it does not already exist.EX 90(expiry): the key auto-expires after 90 seconds, slightly longer than the ticket TTL, to cover clock skew between the issuance time and exchange time.- If
wasSetisfalse, the key was already present — the ticket has already been used. The request is rejected as a replay.
This guarantees that even if an attacker intercepts the ticket URL (e.g., from browser history or a proxy log), replaying it returns 401 ticket_already_used.
When Tickets Are Issued
Every CreateSignInTicket call site in AccountController is listed below. Endpoints are relative to
the controller's v{version:apiVersion}/identity/auth route, so POST /login is served at
/v1/identity/auth/login.
| Scenario | Endpoint | Notes |
|---|---|---|
| Password login, no MFA | POST /login |
Standard flow |
| Password login, org MFA grace period active | POST /login |
Ticket issued; enrollment banner shown |
| MFA challenge passed | POST /mfa/verify |
Issued after TOTP or backup code verification |
| Magic link verified | GET /passwordless/magic-link/verify |
Returns { succeeded, email, signInTicket } |
| OTP verified | POST /passwordless/otp/verify |
Returns { succeeded, email, signInTicket } |
Passwordless flows go through the same ticket exchange as password login — the response shape is
identical, and the auth app navigates to exchange-ticket exactly as it does after a password login.
Tickets are not issued for external OAuth provider logins: that callback is already a direct browser GET, so the API sets the cookie immediately and no hand-off is needed.
Client-Side Exchange (React)
After receiving a successful login response containing a ticket, the login screen builds the exchange URL through the SDK and assigns globalThis.location.href to it, forcing a full-page navigation:
import { buildExchangeTicketUrl } from "@bc-solutions-coder/sdk";
const SAME_ORIGIN_BASE: string = BASE_PATH;
if (result.signInTicket) {
globalThis.location.href = buildExchangeTicketUrl(
SAME_ORIGIN_BASE,
result.signInTicket,
returnUrl,
clientId,
);
return;
}
The fourth argument is optional. Passing it scopes the endpoint's returnUrl allow-list check to that
client; a nullish or blank value omits the query parameter. MfaChallengeForm.tsx passes its
scopedClientId here for exactly that reason.
Assigning location.href (rather than a client-side router navigation) is required so the browser actually sends the GET to exchange-ticket and receives the Set-Cookie response header. Because the endpoint is reached through the auth app's same-origin proxy, the cookie is set on the correct origin. The MFA challenge screen performs the same exchange after a TOTP or backup-code verification.
Security Properties
| Property | Mechanism |
|---|---|
| Confidentiality | AES-256-CBC encryption via Data Protection |
| Integrity | HMACSHA256 authentication tag |
| Expiry | 60-second TTL enforced by ITimeLimitedDataProtector |
| Single-use | Valkey SET NX — atomic, guaranteed at most one exchange |
| Purpose isolation | "SignInTicket" purpose string prevents cross-purpose decryption |
| Replay TTL headroom | Valkey key expires at 90s (>60s ticket TTL) to handle clock skew |
Organization context
Once the cookie exists, the resumed OIDC authorization decides which organization the issued token belongs to. There is one code path for every client:
| Client | Organization | Enrollment policy |
|---|---|---|
| Third-party, bound to an organization | The bound one. An organization parameter naming any other organization is invalid_request. |
Always runs. |
First-party, organization=<guid> passed |
The hinted one. | Runs exactly as for a bound client. |
| First-party, no hint | The user's single membership, or none when they hold several or zero. | Not run; the org-less token reaches only [AllowWithoutOrganization] endpoints. |
When the policy refuses a user on a bound client — pending, suspended, denied, or not a member —
the transaction ends at the RP's redirect_uri with error=access_denied and
error_description ∈ membership_pending | membership_suspended | membership_denied | not_a_member; a pending outcome still records the membership request. Only an unverified email
stays on the auth host, because that is the user's to fix, not the RP's.
Every sign-in links its tokens to an OpenIddict authorization that records the organization
(org_id in the authorization's properties): a permanent consent authorization for a
third-party client, an ad-hoc one for a first-party sign-in with an organization. Revoking a
membership therefore revokes the tokens that named that organization — whichever client issued
them — alongside the bound-client tokens.
Switching organization is a fresh authorization with the hint: the SDK's
loginRedirect(returnTo, { organization }) builds the link, the SSO cookie makes the
re-authorize silent, and wallow-web's My organizations page is the picker. The auth host has
none.
Key Files
| File | Role |
|---|---|
api/src/Modules/Identity/Wallow.Identity.Api/Controllers/AccountController.cs |
CreateSignInTicket, ValidateSignInTicket, ExchangeTicket endpoint |
apps/wallow-auth/src/features/login/components/LoginScreen.tsx |
Ticket exchange navigation logic |
apps/wallow-auth/src/features/mfa-challenge/components/MfaChallengeForm.tsx |
Ticket exchange after MFA verification |
apps/wallow-auth/src/features/login/auth-result.ts |
Parses the signInTicket field from the login response |
apps/wallow-auth/src/features/login/magic-link-result.ts, otp-result.ts |
The passwordless equivalents |
Related Documentation
- Authorization — what the cookie's claims are allowed to do
- Caching — the Valkey instance the replay guard writes to
- BFF Pattern — how a frontend consumes the session
- External Auth — the OAuth provider flows that skip the ticket