diff --git a/Directory.Packages.props b/Directory.Packages.props
index 7bb487fd9..59e56e359 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -32,6 +32,7 @@
+
diff --git a/ErsatzTV.Application/Auth/AuthConstants.cs b/ErsatzTV.Application/Auth/AuthConstants.cs
new file mode 100644
index 000000000..19e080aa3
--- /dev/null
+++ b/ErsatzTV.Application/Auth/AuthConstants.cs
@@ -0,0 +1,28 @@
+namespace ErsatzTV.Application.Auth;
+
+///
+/// Shared constants for the browser-SPA session authentication (issue #295): the cookie scheme name,
+/// the custom claim types the local-login path stamps onto the principal, and the auth-method marker
+/// values. The web host (cookie OnValidatePrincipal, AuthController) and the Application
+/// handlers both reference these so the claim contract has a single definition.
+///
+public static class AuthConstants
+{
+ /// The cookie authentication scheme name shared by local login and the OIDC callback.
+ public const string CookieScheme = "cookie";
+
+ /// The OIDC challenge scheme name.
+ public const string OidcScheme = "oidc";
+
+ /// Claim type recording how the principal signed in ( / ).
+ public const string AuthMethodClaim = "etv:auth_method";
+
+ /// Claim type carrying the local admin's security stamp (checked on every request to revoke sessions).
+ public const string SecurityStampClaim = "etv:security_stamp";
+
+ public const string MethodLocal = "local";
+ public const string MethodOidc = "oidc";
+
+ /// Minimum length for a local admin password.
+ public const int MinPasswordLength = 8;
+}
diff --git a/ErsatzTV.Application/Auth/ChangeLocalAdminPassword.cs b/ErsatzTV.Application/Auth/ChangeLocalAdminPassword.cs
new file mode 100644
index 000000000..5b35f8525
--- /dev/null
+++ b/ErsatzTV.Application/Auth/ChangeLocalAdminPassword.cs
@@ -0,0 +1,10 @@
+using ErsatzTV.Core;
+
+namespace ErsatzTV.Application.Auth;
+
+///
+/// Changes the local admin password after verifying the current one. Rotates the security stamp so all
+/// other sessions are revoked. is the signed-in principal's name.
+///
+public record ChangeLocalAdminPassword(string Username, string CurrentPassword, string NewPassword)
+ : IRequest>;
diff --git a/ErsatzTV.Application/Auth/ChangeLocalAdminPasswordHandler.cs b/ErsatzTV.Application/Auth/ChangeLocalAdminPasswordHandler.cs
new file mode 100644
index 000000000..fe96c5472
--- /dev/null
+++ b/ErsatzTV.Application/Auth/ChangeLocalAdminPasswordHandler.cs
@@ -0,0 +1,68 @@
+using ErsatzTV.Core;
+using ErsatzTV.Core.Domain;
+using ErsatzTV.Infrastructure.Data;
+using Microsoft.EntityFrameworkCore;
+
+namespace ErsatzTV.Application.Auth;
+
+public class ChangeLocalAdminPasswordHandler(
+ IDbContextFactory dbContextFactory,
+ ILocalPasswordHasher passwordHasher)
+ : IRequestHandler>
+{
+ public async Task> Handle(
+ ChangeLocalAdminPassword request,
+ CancellationToken cancellationToken)
+ {
+ foreach (BaseError error in LocalAdminHelpers.ValidatePassword(request.NewPassword))
+ {
+ return error;
+ }
+
+ await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
+
+ List rows = await dbContext.ConfigElements
+ .Where(c => c.Key == ConfigElementKey.AuthLocalAdminUsername.Key
+ || c.Key == ConfigElementKey.AuthLocalAdminPasswordHash.Key
+ || c.Key == ConfigElementKey.AuthSecurityStamp.Key)
+ .ToListAsync(cancellationToken);
+
+ ConfigElement userRow = rows.Find(r => r.Key == ConfigElementKey.AuthLocalAdminUsername.Key);
+ ConfigElement hashRow = rows.Find(r => r.Key == ConfigElementKey.AuthLocalAdminPasswordHash.Key);
+ ConfigElement stampRow = rows.Find(r => r.Key == ConfigElementKey.AuthSecurityStamp.Key);
+
+ if (hashRow is null)
+ {
+ return BaseError.New("No local administrator is configured");
+ }
+
+ string username = (request.Username ?? string.Empty).Trim();
+ bool userMatches = userRow is not null
+ && string.Equals(userRow.Value, username, StringComparison.OrdinalIgnoreCase);
+
+ LocalPasswordVerification result =
+ passwordHasher.Verify(hashRow.Value, request.CurrentPassword ?? string.Empty);
+
+ if (!userMatches || result == LocalPasswordVerification.Failed)
+ {
+ return BaseError.New("Current password is incorrect");
+ }
+
+ // Atomic: the new hash and rotated stamp commit together, so a crash can't leave the new password
+ // active with the old stamp still authorizing revoked sessions.
+ string stamp = LocalAdminHelpers.NewSecurityStamp();
+ hashRow.Value = passwordHasher.Hash(request.NewPassword);
+ if (stampRow is null)
+ {
+ dbContext.ConfigElements.Add(new ConfigElement { Key = ConfigElementKey.AuthSecurityStamp.Key, Value = stamp });
+ }
+ else
+ {
+ stampRow.Value = stamp;
+ }
+
+ await dbContext.SaveChangesAsync(cancellationToken);
+
+ return new LocalAdminPrincipal(userRow.Value, stamp);
+ }
+}
diff --git a/ErsatzTV.Application/Auth/ClaimLocalAdmin.cs b/ErsatzTV.Application/Auth/ClaimLocalAdmin.cs
new file mode 100644
index 000000000..d35a3d57b
--- /dev/null
+++ b/ErsatzTV.Application/Auth/ClaimLocalAdmin.cs
@@ -0,0 +1,9 @@
+using ErsatzTV.Core;
+
+namespace ErsatzTV.Application.Auth;
+
+///
+/// First-run setup-claim: creates the single local administrator. Fails if one already exists
+/// (first-claim-wins), so a later anonymous call cannot take over the account.
+///
+public record ClaimLocalAdmin(string Username, string Password) : IRequest>;
diff --git a/ErsatzTV.Application/Auth/ClaimLocalAdminHandler.cs b/ErsatzTV.Application/Auth/ClaimLocalAdminHandler.cs
new file mode 100644
index 000000000..550fc1cdb
--- /dev/null
+++ b/ErsatzTV.Application/Auth/ClaimLocalAdminHandler.cs
@@ -0,0 +1,68 @@
+using ErsatzTV.Core;
+using ErsatzTV.Core.Domain;
+using ErsatzTV.Infrastructure.Data;
+using Microsoft.EntityFrameworkCore;
+
+namespace ErsatzTV.Application.Auth;
+
+public class ClaimLocalAdminHandler(IDbContextFactory dbContextFactory, ILocalPasswordHasher passwordHasher)
+ : IRequestHandler>
+{
+ public async Task> Handle(
+ ClaimLocalAdmin request,
+ CancellationToken cancellationToken)
+ {
+ foreach (BaseError error in LocalAdminHelpers.ValidateNewCredentials(request.Username, request.Password))
+ {
+ return error;
+ }
+
+ string username = request.Username.Trim();
+
+ await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
+
+ // Fast path for the common already-configured case (clean 409). The real first-claim-wins guard is
+ // the unique index on ConfigElement.Key + the single atomic SaveChanges below: two concurrent claims
+ // both pass this check, but only one INSERT of the three credential rows commits — the loser's
+ // SaveChanges violates the unique Key index and rolls back wholesale (no mixed-state credential).
+ bool alreadyConfigured = await dbContext.ConfigElements
+ .AnyAsync(c => c.Key == ConfigElementKey.AuthLocalAdminPasswordHash.Key, cancellationToken);
+ if (alreadyConfigured)
+ {
+ return BaseError.New("A local administrator has already been configured");
+ }
+
+ string stamp = LocalAdminHelpers.NewSecurityStamp();
+ dbContext.ConfigElements.AddRange(
+ new ConfigElement { Key = ConfigElementKey.AuthLocalAdminUsername.Key, Value = username },
+ new ConfigElement
+ {
+ Key = ConfigElementKey.AuthLocalAdminPasswordHash.Key,
+ Value = passwordHasher.Hash(request.Password)
+ },
+ new ConfigElement { Key = ConfigElementKey.AuthSecurityStamp.Key, Value = stamp });
+
+ try
+ {
+ await dbContext.SaveChangesAsync(cancellationToken);
+ }
+ catch (DbUpdateException)
+ {
+ // A write conflict here is (almost always) a lost first-claim race — a concurrent claim inserted
+ // these keys first (unique Key index). Confirm the row now exists on a fresh context before
+ // reporting "already configured"; otherwise this was a genuine/transient DB error → rethrow rather
+ // than mask it.
+ await using TvContext verifyContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
+ bool nowConfigured = await verifyContext.ConfigElements
+ .AnyAsync(c => c.Key == ConfigElementKey.AuthLocalAdminPasswordHash.Key, cancellationToken);
+ if (nowConfigured)
+ {
+ return BaseError.New("A local administrator has already been configured");
+ }
+
+ throw;
+ }
+
+ return new LocalAdminPrincipal(username, stamp);
+ }
+}
diff --git a/ErsatzTV.Application/Auth/GetLocalAdminSecurityStamp.cs b/ErsatzTV.Application/Auth/GetLocalAdminSecurityStamp.cs
new file mode 100644
index 000000000..f9fcbf180
--- /dev/null
+++ b/ErsatzTV.Application/Auth/GetLocalAdminSecurityStamp.cs
@@ -0,0 +1,8 @@
+namespace ErsatzTV.Application.Auth;
+
+///
+/// The current local-admin security stamp, or None if no local admin is configured. The cookie
+/// OnValidatePrincipal compares this to the principal's stamp claim on every request; a mismatch
+/// (i.e. the password was changed) rejects the session.
+///
+public record GetLocalAdminSecurityStamp : IRequest