Accounts & Sessions

The building blocks of a sign-in system you write yourself: rotating refresh tokens that detect stolen sessions, the refresh cookie that carries them, one-time tokens for email verification or password resets, and the opaque token format underneath. Combine them with passwords and access tokens and your own controllers; Altruist keeps the security-sensitive parts and stays out of your account model.

The Flow

Loading diagram...

Every sign-in starts a family. A refresh consumes the presented token and issues its successor in the same family. If an already-rotated token comes back — someone copied it — the whole family is revoked: the thief's and the victim's sessions end, and the user signs in again.

Refresh Tokens

1. Declare the table

Derive a vault from RefreshTokenModel. Altruist creates and migrates the table like any vault and registers IRefreshTokenService for it:

[Vault("refresh_tokens", Keyspace: "myapp")]
public sealed class RefreshTokenVault : RefreshTokenModel { }

Columns: principal_id, family_id, token_hash (unique), issued_at, expires_at, used_at, revoked_at, plus the vault columns. Only the SHA-256 of a token is stored: a leaked table holds no usable session.

2. Use it

[ApiController, Route("auth")]
public sealed class AuthController(
    IRefreshTokenService refresh, IAccessTokenIssuer access, RefreshCookie cookie, Accounts accounts) : ControllerBase
{
    [HttpPost("login")]
    public async Task<IActionResult> Login(LoginRequest body, CancellationToken ct)
    {
        var account = await accounts.VerifyAsync(body.Username, body.Password, ct);
        if (account is null) return Unauthorized();

        var session = await refresh.IssueAsync(account.Id, ct: ct);   // a new family
        await refresh.RevokeExcessAsync(account.Id, ct: ct);          // keep the 10 newest sessions
        cookie.Append(Response, session);
        return Ok(new { accessToken = access.Issue(account.Id).Token });
    }

    [HttpPost("refresh")]
    public async Task<IActionResult> Refresh(CancellationToken ct)
    {
        if (!cookie.HasCsrfHeader(Request)) return Forbid();
        var rotation = await refresh.RotateAsync(cookie.Read(Request), ct);
        if (rotation.Outcome != RefreshOutcome.Rotated)
        {
            cookie.Delete(Response);
            return Unauthorized();   // Reused: tell the user the session was ended for security
        }
        cookie.Append(Response, rotation.Next!);
        return Ok(new { accessToken = access.Issue(rotation.PrincipalId!).Token });
    }

    [HttpPost("logout")]
    public async Task<IActionResult> Logout(CancellationToken ct)
    {
        if (!cookie.HasCsrfHeader(Request)) return Forbid();
        await refresh.RevokeFamilyAsync(cookie.Read(Request), ct);
        cookie.Delete(Response);
        return NoContent();
    }
}
Method
IssueAsync(principalId, familyId?)A new raw token (IssuedRefreshToken: Token, FamilyId, ExpiresAt); a new family unless one is given
RotateAsync(raw)Rotated with Next, Invalid (unknown, malformed, expired, revoked) or Reused (family revoked). Atomic: concurrent refreshes of one token succeed once
RevokeFamilyAsync(raw)Sign out this session
RevokeAllAsync(principalId)Sign out everywhere (password change)
RevokeExcessAsync(principalId, max?)Keep the max most recently active families
PruneAsync(now)Delete expired, long-revoked and long-rotated rows (runs automatically)
altruist:
  security:
    refresh-tokens:
      lifetime-days: 30
      max-sessions: 10            # RevokeExcessAsync default; 0 = unlimited
      used-retention-hours: 48    # rotated tokens are kept this long so a replay is still detected
      revoked-retention-hours: 24

Transactions

Every call joins the ambient SQL transaction. Create the account and its first token atomically:

await ((ISqlTransactionProvider)db).InTransactionAsync(async ct =>
{
    await accounts.InsertAsync(account, ct);
    return await refresh.IssueAsync(account.Id, ct: ct);
});

Using an existing table

Override a property and give it the existing column name — the table is used as it is:

[Vault("refresh_tokens", Keyspace: "legacy")]
public sealed class RefreshTokenVault : RefreshTokenModel
{
    [VaultColumn("account_id"), VaultColumnIndex]
    public override string PrincipalId { get; set; } = "";
}

Tokens stored before keep working as long as their hash is the opaque token hash.

RefreshCookie keeps the refresh token in an HttpOnly cookie that page scripts cannot read, scoped to your auth routes, Secure on HTTPS (forwarded headers count). Endpoints that act on the cookie must require a custom header (HasCsrfHeader): a cross-site form cannot send one, and a cross-origin script would need a CORS preflight you never grant.

altruist:
  security:
    refresh-cookie:
      name: refresh_token
      path: /auth           # only sent to the auth routes
      same-site: strict     # strict | lax | none
      csrf-header: X-Requested-With
      csrf-value: "1"
await fetch("/auth/refresh", { method: "POST", credentials: "include", headers: { "X-Requested-With": "1" } });

One-Time Tokens

Single-use links for email verification, password resets or invitations: a subject, an optional payload, a lifetime. Declare one vault per purpose:

[Vault("email_verifications", Keyspace: "myapp")]
public sealed class EmailVerificationVault : OneTimeTokenModel { }
public sealed class EmailVerification(IOneTimeTokenStore<EmailVerificationVault> links, ISqlDatabaseProvider db)
{
    private ISqlTransactionProvider tx => (ISqlTransactionProvider)db;

    public Task<string> CreateAsync(string accountId, string email) =>
        links.CreateAsync(accountId, email, TimeSpan.FromHours(24));   // older links of the account stop working

    public Task<bool> VerifyAsync(string? token) => tx.InTransactionAsync(async ct =>
    {
        var link = await links.ConsumeAsync(token, ct: ct);
        if (link.Status != OneTimeTokenStatus.Ok) return false;          // Invalid, Expired or Used
        return await MarkVerifiedAsync(link.SubjectId!, link.Payload!, ct); // rolls back with the consumption
    });
}

ConsumeAsync returns the status with the subject and payload (also for Expired and Used, to word the message). Columns: subject_id, payload, token_hash, issued_at, expires_at, used_at; override them like the refresh token's. With exactly one one-time vault, IOneTimeTokenStore is registered too. Expired tokens are deleted a day after they expire (altruist:security:one-time-tokens:expired-retention-hours).

Opaque Tokens

The format under all of the above (and connection tickets):

var raw = OpaqueToken.New();           // 256 random bits, base64url, 43 characters: give it to the client
var stored = OpaqueToken.Hash(raw);    // lower-case hex SHA-256: store and look up this
if (!OpaqueToken.IsWellFormed(input))  // cheap rejection of junk before a lookup
    return null;

Pruning

TokenPruneService deletes stale rows of every token store at start-up and then every altruist:security:token-prune:interval-minutes (60; 0 turns it off). Failures (database down) are logged and retried at the next interval.

Note:

The stores use the default ISqlDatabaseProvider and need transactions (Postgres): rotation and consumption lock the token row. Nothing is registered for apps without token vaults.