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:

  1. The abuse gate checked four rate-limit layers (per-IP, per-IP-and-account, account lockout, credential-stuffing heuristics) before touching the database.
  2. 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.
  3. The credential verified, and would have been transparently rehashed if it carried an older algorithm tag.
  4. 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.
  5. 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.

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