Authentication & Authorization
Altruist provides built-in JWT authentication, a Shield system for protecting portals and HTTP endpoints, and a session upgrade flow for real-time connections.
Config
altruist:
security:
mode: "jwt"
key: "your-base64-secret-key"
Note:
Setting mode: "jwt" enables the built-in JwtAuth, JwtTokenValidator, JwtTokenIssuer and IAccessTokenIssuer services automatically.
Building your own account system (registration, login, sessions)? Accounts & Sessions covers rotating refresh tokens, refresh cookies and one-time tokens; Connection Tickets covers authenticating browser WebSockets.
All Security Settings
altruist:
security:
mode: "jwt"
key: "" # from ALTRUIST__SECURITY__KEY in production
min-key-bytes: 32 # shorter keys are refused in Production
access-token-minutes: 15 # IAccessTokenIssuer default lifetime
jwt:
map-inbound-claims: true # false: claims stay as issued ("sub", "name")
name-claim: "" # the claim User.Identity.Name reads, e.g. "name"
password:
work-factor: 11 # BCrypt cost
prehash: none # none | sha384
lockout:
store: memory # memory | backplane (shared by the fleet)
captcha:
provider: turnstile # turnstile | hcaptcha | recaptcha
site-key: ""
secret-key: "" # empty = captcha off
Lockouts and the captcha: Lockout & Captcha.
Key Guard
Every token is signed with altruist:security:key. When it is not set, Altruist falls back to a public development key (AuthConfiguration.DevelopmentKey) — anyone can sign tokens with it. So in Production (see AltruistEnvironment), an app with an altruist:security section refuses to start when the key is unset, the development key, or shorter than min-key-bytes (32 bytes). Other environments log a warning.
# a random 48-byte secret
ALTRUIST__SECURITY__KEY=$(openssl rand -base64 48)
Bearer validation is pinned to HS256 and requires signed tokens with an expiry: a token signed with the same key but another algorithm (HS384, HS512), an unsigned token or one without exp is rejected.
Passwords
IPasswordHasher (BcryptPasswordHasher) hashes and verifies passwords with BCrypt:
public class Accounts(IPasswordHasher hasher)
{
public string HashFor(string password) => hasher.Hash(password);
// account?.PasswordHash may be null (unknown user): the call still costs one BCrypt verify,
// so an attacker cannot tell existing users by timing. A malformed hash returns false.
public bool CanSignIn(Account? account, string password) =>
hasher.Verify(password, account?.PasswordHash) && account is not null;
}
| Setting | Default | |
|---|---|---|
password:work-factor | 11 | BCrypt cost (4–31); each step doubles the time |
password:prehash | none | sha384 hashes the password with SHA-384 first (BCrypt.Net's enhanced mode), so passwords longer than BCrypt's 72-byte limit are not truncated |
Note:
Pick prehash before you store hashes: hashes of one mode do not verify in the other.
Access Tokens
IAccessTokenIssuer issues short-lived JWT access tokens that [Authorize] and [JwtShield] accept. It is stateless — every call carries its own claims — so inject it anywhere:
var token = issuer.Issue(account.Id, new[] { new Claim("name", account.Username) });
return new { accessToken = token.Token, expiresIn = token.ExpiresInSeconds };
The token holds sub (the subject), your claims, a random jti and iat / nbf / exp, signed HS256 with the configured key, issuer and audience. The lifetime is altruist:security:access-token-minutes (15) unless you pass one: issuer.Issue(id, lifetime: TimeSpan.FromMinutes(5)).
Note:
JwtTokenIssuer.WithClaims(claims) returns a new issuer for those claims. (It used to set them on the shared singleton, so concurrent requests could issue tokens with another user's claims.)
Who Is Calling
var id = User.PrincipalId(); // "sub" (or NameIdentifier when inbound claims are mapped)
var name = User.PrincipalName(); // Identity.Name, else the "name" claim
var ip = HttpContext.ClientIp(); // after forwarded headers and the fleet relay restored it
With jwt:map-inbound-claims: false, ASP.NET leaves the claims as issued (sub, name) instead of renaming them to long ClaimTypes URIs; with jwt:name-claim: name, User.Identity.Name is the name claim.
Error Bodies
JWT bearer failures answer with empty 401 / 403 responses. With errors:format: simple they answer {"error": "unauthorized" | "forbidden", "message": ...} like every other API failure. Register an IAuthChallengeWriter to write your own body:
[Service(typeof(IAuthChallengeWriter))]
public sealed class JsonChallenge : IAuthChallengeWriter
{
public Task WriteAsync(HttpContext context, int statusCode)
{
context.Response.StatusCode = statusCode;
return context.Response.WriteAsJsonAsync(statusCode == 401
? new { error = "unauthorized", message = "Please sign in again." }
: new { error = "forbidden", message = "You are not allowed to do that." });
}
}
JWT Auth Controller
Extend JwtAuthController to get signup, login, refresh, and upgrade endpoints:
[ApiController]
[Route("/auth")]
public class AuthController : JwtAuthController
{
public AuthController(
IJwtTokenValidator validator,
ILoginService loginService,
TokenSessionSyncService syncService,
IJwtTokenIssuer issuer,
IAuthService authService,
ILoggerFactory loggerFactory)
: base(validator, loginService, syncService, issuer, authService, loggerFactory)
{
}
}
This gives you these endpoints:
| Endpoint | Method | Description |
|---|---|---|
/auth/signup | POST | Create a new account |
/auth/login/emailpwd | POST | Login with email + password |
/auth/login/unamepwd | POST | Login with username + password |
/auth/refresh | POST | Refresh an expired access token |
/auth/upgrade | POST | Upgrade JWT to session token |
Signup
curl -X POST http://localhost:3000/auth/signup \
-H "Content-Type: application/json" \
-d '{"email": "player@example.com", "username": "hero", "password": "secret123"}'
Login
curl -X POST http://localhost:3000/auth/login/unamepwd \
-H "Content-Type: application/json" \
-d '{"username": "hero", "password": "secret123"}'
Response:
{
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
"refreshToken": "eyJhbGciOiJIUzI1NiIs..."
}
Client Types (HTTP Auth)
public class LoginResponse
{
[JsonPropertyName("accessToken")] public string AccessToken { get; set; } = "";
[JsonPropertyName("refreshToken")] public string RefreshToken { get; set; } = "";
}
public class UpgradeResponse
{
[JsonPropertyName("accessToken")] public string AccessToken { get; set; } = "";
[JsonPropertyName("refreshToken")] public string RefreshToken { get; set; } = "";
[JsonPropertyName("accessExpiration")] public DateTime AccessExpiration { get; set; }
[JsonPropertyName("refreshExpiration")] public DateTime RefreshExpiration { get; set; }
}
Custom Claims
Override GetClaimsForLogin to add game-specific claims:
protected override IEnumerable<Claim> GetClaimsForLogin(AccountModel account, LoginRequest request)
{
var claims = base.GetClaimsForLogin(account, request).ToList();
claims.Add(new Claim("AccountId", account.StorageId));
claims.Add(new Claim(ClaimTypes.Role, "player"));
return claims;
}
The Shield System
[Shield] protects portals and HTTP controllers. It works across all transports — WebSocket, TCP, UDP, and HTTP.
Protecting a Portal
[Portal("/game")]
[JwtShield]
public class SecureGamePortal : AltruistPortal
{
[Gate("action")]
public async Task OnAction(ActionPacket packet, string clientId)
{
// Only authenticated clients reach here
}
}
Protecting an HTTP Controller
[ApiController]
[Route("/api/admin")]
[JwtShield]
public class AdminController : ControllerBase
{
[HttpGet("stats")]
public IActionResult GetStats()
{
// Only requests with valid JWT reach here
}
}
Note:
[JwtShield] is a shorthand for [Shield(typeof(JwtAuth))]. Both work the same way.
Method-Level Shield
Apply shields to individual methods instead of the whole class:
[Portal("/game")]
public class GamePortal : AltruistPortal
{
[Gate("public-chat")]
public async Task OnPublicChat(ChatPacket p, string clientId)
{
// No auth required
}
[JwtShield]
[Gate("private-action")]
public async Task OnPrivateAction(ActionPacket p, string clientId)
{
// Auth required for this gate only
}
}
How Shield Works
Custom Shield
You can create your own authentication mechanism by implementing IShieldAuth. See Custom Shield for the full guide with examples.
ASP.NET [Authorize]
Standard ASP.NET [Authorize] works on HTTP controllers. Altruist's JWT config sets up the authentication middleware automatically:
[ApiController]
[Route("/api/data")]
[Authorize]
public class DataController : ControllerBase
{
[HttpGet]
public IActionResult GetData()
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
return Ok(new { userId });
}
[Authorize(Roles = "admin")]
[HttpDelete("{id}")]
public IActionResult Delete(string id) { ... }
}
Shield vs Authorize
[JwtShield] | [Authorize] | |
|---|---|---|
| HTTP | Yes | Yes |
| WebSocket | Yes | No |
| TCP / UDP | Yes | No |
| Portals | Yes | No |
| Controllers | Yes | Yes |
| Role-based | Via claims | Built-in Roles = "admin" |
Note:
Use [JwtShield] as your default — it works everywhere. Use [Authorize] only when you need ASP.NET-specific features like role policies on HTTP controllers.