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;
}
SettingDefault
password:work-factor11BCrypt cost (4–31); each step doubles the time
password:prehashnonesha384 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:

EndpointMethodDescription
/auth/signupPOSTCreate a new account
/auth/login/emailpwdPOSTLogin with email + password
/auth/login/unamepwdPOSTLogin with username + password
/auth/refreshPOSTRefresh an expired access token
/auth/upgradePOSTUpgrade 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

Loading diagram...

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]
HTTPYesYes
WebSocketYesNo
TCP / UDPYesNo
PortalsYesNo
ControllersYesYes
Role-basedVia claimsBuilt-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.