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
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.
Refresh Cookie
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.