Articles · Getting Started
Your first login
Try out the running example
By the end of this article you will have an ASP.NET Core app that is an identity provider: it stores users, verifies passwords with argon2id, mints RS256 access tokens and rotating refresh tokens, and serves a profile API — all inside your own process.
The pieces
Three packages, three registration calls, two endpoint groups:
// file: Program.cs
var builder = WebApplication.CreateBuilder(args);
// Stores claim their ports first — every Sentinel registration is TryAdd,
// so the first registration wins and AddSentinel() won't override them.
builder.Services.AddSentinelEfCoreStores(o => o.UseSqlite("Data Source=sentinel.db"));
builder.Services.AddSentinel();
builder.Services.AddSentinelAuthentication(o =>
{
o.Issuer = "https://localhost:5001";
o.Audience = "first-login-api";
o.DefaultRealmId = SeedData.RealmId;
o.Transport = SentinelTokenTransport.BearerAndCookie;
});
var app = builder.Build();
app.UseAuthentication();
app.MapSentinelAuth(); // /auth/*
app.MapSentinelProfile(); // /profile/*
app.Run();
For development we let the signing key ring generate ephemeral keys — explicitly, because outside Development an unconfigured key ring fails the boot on purpose:
builder.Services.AddSingleton(new SentinelKeyOptions
{
AllowEphemeralDevelopmentKeys = true, // dev only; keys die with the process
});
Create the schema (no shipped migrations yet this wave — EnsureCreated is the dev
path) and seed one user with an argon2id credential through the EF stores.
Login
POST /auth/login
Content-Type: application/json
{ "email": "ada@example.com", "password": "hunter2!" }
{
"status": "ok",
"accessToken": "eyJhbGciOiJSUzI1NiIs…",
"refreshToken": "srt_Q29uZ3JhdHVsYXRpb25zIGN1cmlvdXM…"
}
Worth pausing on what just happened inside LoginService.LoginWithPasswordAsync:
- The abuse gate checked four rate-limit layers (per-IP, per-IP-and-account, account lockout, credential-stuffing heuristics) before touching the database.
- The user lookup ran — and if the email had been unknown, Sentinel would have burned a dummy argon2id verification so the response time doesn’t leak which emails exist.
- The credential verified, and would have been transparently rehashed if it carried an older algorithm tag.
- The risk gate scored the attempt (new device, impossible travel, IP reputation, velocity). A high score would have demanded MFA step-up or blocked outright.
- A session was created and tokens minted: a 10-minute RS256 access token and a 30-day rotating refresh token.
Decode the access token and you’ll find the claim set Sentinel always mints:
{
"iss": "https://localhost:5001",
"aud": "first-login-api",
"sub": "0198c1c2-…",
"realm": "0198c1c0-…",
"sid": "0198c1c3-…",
"mfa": "none",
"iat": 1755100000,
"exp": 1755100600,
"jti": "0198c1c4-…"
}
The header’s typ is at+sentinel — and that matters. Sentinel mints several
single-purpose token types (mfa+sentinel, pwreset+sentinel, everify+sentinel), and
verification always enforces typ and aud. A password-reset token presented as an
access token is rejected structurally, not by convention.
The authenticated call
GET /profile/me
Authorization: Bearer eyJhbGciOiJSUzI1NiIs…
The Sentinel authentication handler resolves credentials in a fixed order — Bearer
header, then access-token cookie, then snt_ API key — and establishes a
SentinelPrincipal. In your own endpoints:
app.MapGet("/whoami", (HttpContext http) =>
{
var p = http.GetSentinelPrincipal();
return p is null
? Results.Unauthorized()
: Results.Ok(new { p.SubjectId, p.RealmId, p.SessionId, p.Kind });
});
Refresh — and the trap built into it
POST /auth/refresh
Content-Type: application/json
{ "refreshToken": "srt_Q29uZ3JhdHVsYXRpb25z…" }
You get a new refresh token; the old one is atomically marked used. Replay the old
one and the response is 401 invalid_refresh_token — and behind it, Sentinel revoked
the entire token family and emitted token.refresh_reuse_detected. The HTTP test
Refresh_rotates_and_reuse_is_rejected pins the whole behavior, including the detail
that the freshly rotated token dies with the family too.
Article 003 dissects the mechanism.
Cookie mode, briefly
With Transport = BearerAndCookie (the default here), the same login also set httpOnly
cookies: sentinel_at, sentinel_rt (path-scoped to /auth/refresh), and a
non-httpOnly sentinel_csrf. A browser app can ignore the JSON tokens entirely: cookies
carry authentication, and unsafe methods pass the CSRF value back in the
X-Sentinel-Csrf header — double-submit, enforced by the handler. Cookie-first is the
recommended posture for web apps; Bearer is for APIs, machines and mobile.
Errors are contracts
Failures return problem+json with stable machine-readable codes — the message is
localized from Accept-Language, but the error code never changes:
{ "status": 401, "error": "invalid_credentials", "message": "Invalid email or password." }
invalid_credentials (401), blocked (429), captcha_required (429, with the CAPTCHA
site key in the extensions), invalid_refresh_token (401), unauthenticated (401).
Note what’s not distinguishable from the outside: wrong password vs unknown user vs —
by default — a locked account. Uniformity is the anti-enumeration defense; see the
user enumeration recipe.
Next steps
- 002 — The permission grammar adds authorization to this host.
- 004 — Passkeys upgrades login to phishing-resistant.
- The getting started guide covers the same ground in reference form.