diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/AssemblyInfo.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/AssemblyInfo.cs
new file mode 100644
index 0000000..afc932a
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/AssemblyInfo.cs
@@ -0,0 +1,3 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("ZB.MOM.WW.OtOpcUa.Security.Tests")]
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/CookieOptions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/CookieOptions.cs
new file mode 100644
index 0000000..8afb320
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/CookieOptions.cs
@@ -0,0 +1,11 @@
+namespace ZB.MOM.WW.OtOpcUa.Security;
+
+public sealed class OtOpcUaCookieOptions
+{
+ public const string SectionName = "Security:Cookie";
+
+ public string Name { get; set; } = "OtOpcUa.Auth";
+
+ /// Idle sliding window, in minutes (default 30).
+ public int ExpiryMinutes { get; set; } = 30;
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..657c794
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs
@@ -0,0 +1,73 @@
+using Microsoft.AspNetCore.Authentication.Cookies;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.AspNetCore.DataProtection;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using ZB.MOM.WW.OtOpcUa.Configuration;
+using ZB.MOM.WW.OtOpcUa.Security.Jwt;
+using ZB.MOM.WW.OtOpcUa.Security.Ldap;
+
+namespace ZB.MOM.WW.OtOpcUa.Security;
+
+public static class ServiceCollectionExtensions
+{
+ ///
+ /// Wires cookie+JWT hybrid authentication. Cookies are the primary scheme for browser-facing
+ /// Blazor + Razor flows; JWT bearer is layered in for external API consumers (OPC UA client
+ /// tools, scripts). DataProtection keys persist to the shared ConfigDb so cookies survive
+ /// failover between nodes.
+ ///
+ public static IServiceCollection AddOtOpcUaAuth(this IServiceCollection services, IConfiguration configuration)
+ {
+ services.AddOptions().Bind(configuration.GetSection(JwtOptions.SectionName));
+ services.AddOptions().Bind(configuration.GetSection(OtOpcUaCookieOptions.SectionName));
+ services.AddOptions().Bind(configuration.GetSection(LdapOptions.SectionName));
+
+ services.AddSingleton();
+ services.AddScoped();
+
+ services.AddDataProtection()
+ .PersistKeysToDbContext()
+ .SetApplicationName("OtOpcUa");
+
+ services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
+ .AddCookie(o =>
+ {
+ o.Cookie.Name = "OtOpcUa.Auth";
+ o.Cookie.HttpOnly = true;
+ o.Cookie.SameSite = SameSiteMode.Strict;
+ o.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
+ o.SlidingExpiration = true;
+ o.ExpireTimeSpan = TimeSpan.FromMinutes(30);
+ o.Events.OnRedirectToLogin = ctx =>
+ {
+ ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
+ return Task.CompletedTask;
+ };
+ o.Events.OnRedirectToAccessDenied = ctx =>
+ {
+ ctx.Response.StatusCode = StatusCodes.Status403Forbidden;
+ return Task.CompletedTask;
+ };
+ })
+ .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, o =>
+ {
+ using var scope = services.BuildServiceProvider().CreateScope();
+ var jwt = scope.ServiceProvider.GetRequiredService();
+ o.TokenValidationParameters = jwt.BuildValidationParameters();
+ });
+
+ services.AddAuthorization(o =>
+ {
+ o.FallbackPolicy = new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder(
+ CookieAuthenticationDefaults.AuthenticationScheme,
+ JwtBearerDefaults.AuthenticationScheme)
+ .RequireAuthenticatedUser()
+ .Build();
+ });
+
+ return services;
+ }
+}