diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Jwt/JwtOptions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Jwt/JwtOptions.cs
new file mode 100644
index 0000000..9541be8
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Jwt/JwtOptions.cs
@@ -0,0 +1,16 @@
+namespace ZB.MOM.WW.OtOpcUa.Security.Jwt;
+
+public sealed class JwtOptions
+{
+ public const string SectionName = "Security:Jwt";
+ public const int MinSigningKeyBytes = 32;
+
+ /// HS256 signing key. Must be at least 32 bytes (256 bits) UTF-8.
+ public string SigningKey { get; set; } = string.Empty;
+
+ public string Issuer { get; set; } = "otopcua";
+ public string Audience { get; set; } = "otopcua";
+
+ /// Default token expiry. Mirrors ScadaLink (15 min).
+ public int ExpiryMinutes { get; set; } = 15;
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Jwt/JwtTokenService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Jwt/JwtTokenService.cs
new file mode 100644
index 0000000..499046b
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Jwt/JwtTokenService.cs
@@ -0,0 +1,102 @@
+using System.IdentityModel.Tokens.Jwt;
+using System.Security.Claims;
+using System.Text;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Microsoft.IdentityModel.Tokens;
+
+namespace ZB.MOM.WW.OtOpcUa.Security.Jwt;
+
+public sealed class JwtTokenService
+{
+ public const string DisplayNameClaimType = "DisplayName";
+ public const string UsernameClaimType = "Username";
+ public const string RoleClaimType = "Role";
+
+ private readonly JwtOptions _options;
+ private readonly ILogger _logger;
+
+ public JwtTokenService(IOptions options, ILogger logger)
+ {
+ _options = options.Value;
+ _logger = logger;
+
+ var keyByteLength = string.IsNullOrEmpty(_options.SigningKey)
+ ? 0
+ : Encoding.UTF8.GetByteCount(_options.SigningKey);
+ if (keyByteLength < JwtOptions.MinSigningKeyBytes)
+ {
+ throw new InvalidOperationException(
+ $"JwtOptions.SigningKey must be at least {JwtOptions.MinSigningKeyBytes} bytes " +
+ $"(256 bits) for HMAC-SHA256; the configured key is {keyByteLength} byte(s).");
+ }
+ }
+
+ public string Issue(string displayName, string username, IReadOnlyList roles)
+ {
+ var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SigningKey));
+ var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
+
+ var claims = new List
+ {
+ new(DisplayNameClaimType, displayName),
+ new(UsernameClaimType, username),
+ };
+ foreach (var role in roles)
+ claims.Add(new Claim(RoleClaimType, role));
+
+ var token = new JwtSecurityToken(
+ issuer: _options.Issuer,
+ audience: _options.Audience,
+ claims: claims,
+ expires: DateTime.UtcNow.AddMinutes(_options.ExpiryMinutes),
+ signingCredentials: credentials);
+
+ return new JwtSecurityTokenHandler().WriteToken(token);
+ }
+
+ public bool TryValidate(string token, out ClaimsPrincipal? principal)
+ {
+ principal = null;
+ var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SigningKey));
+ var parameters = new TokenValidationParameters
+ {
+ ValidateIssuer = true,
+ ValidIssuer = _options.Issuer,
+ ValidateAudience = true,
+ ValidAudience = _options.Audience,
+ ValidateLifetime = true,
+ ValidateIssuerSigningKey = true,
+ IssuerSigningKey = key,
+ ClockSkew = TimeSpan.Zero,
+ };
+
+ try
+ {
+ var handler = new JwtSecurityTokenHandler();
+ principal = handler.ValidateToken(token, parameters, out _);
+ return true;
+ }
+ catch (Exception ex) when (ex is SecurityTokenException or ArgumentException)
+ {
+ _logger.LogDebug(ex, "JWT validation failed");
+ return false;
+ }
+ }
+
+ ///
+ /// Returns the validation parameters that the JwtBearer middleware should use. Centralised
+ /// so the bearer pipeline can't drift from .
+ ///
+ public TokenValidationParameters BuildValidationParameters() => new()
+ {
+ ValidateIssuer = true,
+ ValidIssuer = _options.Issuer,
+ ValidateAudience = true,
+ ValidAudience = _options.Audience,
+ ValidateLifetime = true,
+ ValidateIssuerSigningKey = true,
+ IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SigningKey)),
+ ClockSkew = TimeSpan.Zero,
+ };
+}