A from-scratch authentication & identity API in ASP.NET Core - built to demonstrate real security engineering practice (not just consume a framework's defaults), and to be genuinely explainable line-by-line in an interview.
I work professionally with identity & access management (Microsoft Entra ID, SSO, claims and roles) at my day job - but that code belongs to my employer and can never be public. AuthGuard is the same problem space, designed and written independently, so I have a piece of code I can actually show and defend: every security decision below has a reason, not just a library default.
A complete authentication system - an ASP.NET Core API plus an Angular client that actually consumes every endpoint:
- Register / log in with a username + password
- Argon2id password hashing (not bcrypt/PBKDF2 - see below), with new passwords screened against Have I Been Pwned's breach corpus
- JWT access tokens + rotating refresh tokens, with reuse detection
- Role-based authorization (
[Authorize(Roles = "Admin")]) - Account lockout after repeated failed logins
- Per-IP rate limiting on the authentication endpoints specifically
- Optional TOTP multi-factor authentication (Google Authenticator-compatible), with a real QR-code enrollment flow in the client, single-use recovery codes, and a password-confirmed way to turn it back off
- An audit log of every security-relevant event, browsable from an admin-only page
- An admin UI for role assignment, so promoting/demoting a user is a real audited action rather than a direct-SQL update
Argon2id instead of bcrypt/PBKDF2. Argon2id won the 2015 Password Hashing Competition and is OWASP's current recommended default for new applications - it resists GPU/ASIC cracking better than bcrypt and PBKDF2 because it's memory-hard, not just CPU-hard. Parameters (64 MB memory, 3 iterations, degree of parallelism 4) target roughly 200-400ms per hash on typical server hardware - deliberately expensive, since slowing down the attacker is the entire point.
Argon2's memory cost is bounded process-wide, not just per-request. 64 MB and a
few hundred milliseconds per hash is the point against a password cracker, but with no
other limit in the way, an unbounded flood of concurrent login/register/MFA-disable
requests could still pile up gigabytes of memory and CPU - the per-IP rate limit blunts
this (10/minute/IP) but doesn't remove it, since plenty of IPs can still arrive at
once. Argon2PasswordHasher gates every hash through a process-wide semaphore sized to
Environment.ProcessorCount; past that limit, extra callers queue (costing latency)
rather than each grabbing another 64 MB (costing memory) at once. HashAsync /
VerifyAsync are async specifically so a queued caller's thread isn't blocked while it
waits.
New passwords are checked against Have I Been Pwned's breach corpus. OWASP now
weighs this above composition rules - a 12-character minimum plus "not a password
already sitting in an attacker's dictionary" does more than requiring a symbol ever
did. HibpPasswordBreachChecker uses the k-anonymity range API: only the first 5 hex
characters of the password's SHA-1 hash are ever sent, HIBP returns every suffix
sharing that prefix, and the match happens locally - the plaintext password, and even
its full hash, never leaves this process. If HIBP is slow or unreachable the check
fails open (registration proceeds) rather than making a third-party outage able to
take signups down.
Refresh token rotation with reuse detection, scoped to the rotation chain (family).
Every time a refresh token is used, it is revoked and replaced by a new one that
inherits its FamilyId - a Guid set fresh on login and carried through every
rotation of that one continuous session. If an already-rotated (i.e. old) token is
ever presented again, that can only mean someone is replaying a stolen copy - at that
point we can't tell the attacker from the legitimate user, so the safe response is to
revoke every token in that family, forcing a fresh login on that chain. A second
device with its own, separate login has a different FamilyId and is untouched: theft
of one session no longer forces every device an account is signed in on to log back
in too. This is the same mitigation used by Auth0 and IdentityServer, not a bespoke
invention.
Refresh token rotation is protected against a genuine race, not just the client's own
serialised refreshes. Two simultaneous refresh calls with the same token could both
pass the IsActive check before either had written anything, producing two live
rotation chains from one token - and the later of the two legitimate calls could then
look like reuse to the other one, logging the user out for nothing. RefreshToken
carries a hand-maintained ConcurrencyStamp ([ConcurrencyCheck]) that's bumped to a
fresh value on every write that revokes a row; EF includes it in the UPDATE's WHERE
clause, so whichever request commits first wins and the second's write matches zero
rows and throws DbUpdateConcurrencyException - caught and turned into a plain
"invalid token" response, not reuse detection, since a request that lost an ordinary
race was never in a position to observe theft. The client still serialises its own
refreshes into one call regardless (token-refresh.coordinator.ts), but this is what
covers a second tab, a second client, or a retried request that the client-side
coordinator never sees.
Refresh tokens are hashed at rest. Only a SHA-256 hash of each refresh token is stored; a stolen database dump alone can't be used to forge sessions.
Generic, identical failure messages. "Invalid username or password" is returned whether the username doesn't exist or the password is wrong - an attacker must not be able to enumerate valid usernames by comparing responses.
Constant-time hash comparison. Verifying a password hash uses
CryptographicOperations.FixedTimeEquals, not ==, so an attacker can't recover the
hash byte-by-byte through response-timing differences.
The two login failure paths take the same amount of time. An identical error message is only half of an anti-enumeration defence. Returning early on an unknown username - without hashing anything - makes that path around 150x faster than a wrong password against a real account, because Argon2 is deliberately slow. The response time alone then answers "does this account exist?", no statistics required. So the unknown-username path verifies the submitted password against a throwaway dummy hash and throws the result away, purely to spend the same CPU. There is a test that measures both paths and fails if the gap reopens.
A failed MFA code counts towards the lockout. Six digits is a million combinations, and the ±1 step verification window means three of them are valid at any instant. If wrong TOTP codes were free, the second factor would be the one credential on the account with no per-account brute-force limit - and per-IP rate limiting does not cover it, because an attacker can simply bring more IPs.
Recovery codes, hashed, single-use. A second factor that can be permanently lost with a phone is a second factor that causes more account loss than it prevents. Ten 50-bit codes are issued when MFA is confirmed, shown exactly once, and stored only as SHA-256 hashes. Each works once. Turning MFA off requires the account password again - stripping the second factor is precisely the move an attacker holding a stolen access token would want to make, so a valid token alone is not sufficient authority.
An enrollment in progress never overwrites a working secret. A freshly generated
TOTP secret is parked in a separate PendingMfaSecret column and only promoted once
the user proves, with a live code, that it actually reached their phone. Writing it
straight into the live secret means a user who starts a second enrollment and wanders
off has silently replaced the secret their authenticator holds - locking themselves
out of their own account with no way back. Re-enrolling while MFA is already on is
refused outright for the same reason.
No real secret is ever committed. The JWT signing key lives in
.NET user-secrets
locally, or an environment variable in deployment - never in appsettings.json. This
project exists partly because an earlier project of mine (Tontine)
had a real Firebase key and admin password committed to source; this repository is
built the way that one should have been from the start.
Rate limiting is scoped to the endpoints that matter. /api/auth/login,
/register, and /refresh get a strict 10 requests/minute/IP policy via ASP.NET Core's
built-in rate limiter - the rest of the API is unrestricted, because blanket rate
limiting everywhere just punishes normal users without meaningfully slowing an attacker
down at the one endpoint they actually care about.
X-Forwarded-For is only ever trusted from an explicit allowlist, never from
anyone. Both that rate limiter and the audit log key off
HttpContext.Connection.RemoteIpAddress - behind any real reverse proxy or load
balancer that's the proxy's address unless something tells ASP.NET Core to read the
forwarded header instead. ForwardedHeadersConfigurator only wires that up when
ForwardedHeaders:KnownProxies / KnownNetworks name specific proxies to trust
(disabled by default); flip Enabled on without configuring either and it refuses to
trust the header at all rather than accept it from anyone, logging a warning instead -
an X-Forwarded-For accepted from any source is worse than not reading it, since an
attacker would just send whatever value defeats the rate limiter and forges the audit
trail. This also had to explicitly clear the framework's own default allowlist
(loopback, 127.0.0.0/8 and ::1, trusted out of the box) rather than add to it -
configuration is the only source of truth for who's trusted here, not an implicit
framework default.
src/AuthGuard.Api/
├── Controllers/ # Thin HTTP layer - no business logic lives here
├── Middleware/
│ └── GlobalExceptionHandler.cs # Unhandled exception -> generic ProblemDetails
├── Services/
│ ├── Argon2PasswordHasher.cs # Password hashing (+ the dummy hash used for
│ │ # login timing equalisation)
│ ├── TokenService.cs # JWT + refresh token generation/hashing
│ ├── TotpMfaService.cs # RFC 6238 TOTP second factor
│ ├── RecoveryCodeService.cs # Single-use MFA recovery codes
│ ├── HibpPasswordBreachChecker.cs # New-password check against known breaches
│ ├── AuditService.cs # Security event log
│ └── AuthService.cs # Orchestrates all of the above; the real logic
├── Models/ # EF Core entities (User, RefreshToken, MfaRecoveryCode,
│ # AuditLogEntry)
└── Data/AppDbContext.cs
tests/AuthGuard.Tests/ # 43 unit tests against AuthService directly (EF Core
│ # InMemory, real Argon2/JWT/TOTP - only the DB is swapped)
└── Integration/ # 13 tests that boot the real API in-process via
# WebApplicationFactory: [Authorize(Roles=...)], the JWT
# claim mapping, and the rate limiter - none of which a
# unit test against AuthService can reach, and all of
# which are configuration that can be silently wrong
client/ # Angular 21 + Material SPA that consumes the full API
├── src/app/core/
│ ├── services/auth.service.ts # Token state (signals), all API calls
│ ├── interceptors/
│ │ ├── auth.interceptor.ts # Attaches bearer token; refresh-and-retry on 401
│ │ └── token-refresh.coordinator.ts # Serialises concurrent refreshes into one call
│ └── guards/ # Route guards - UX only, NOT the real security boundary
└── src/app/features/
├── login/ register/ # requiresMfa handling built into the login form
├── setup/ # One-time "create the first Admin" form
├── dashboard/ # protected home, live access-token countdown
├── mfa-setup/ # QR enrollment, recovery codes, disable flow
├── admin-audit/ # Admin-only audit log table
└── admin-users/ # Admin-only role assignment
Requires the .NET 8 SDK (global.json pins the exact SDK version) and Node 22+.
API:
cd src/AuthGuard.Api
dotnet user-secrets set "Jwt:SigningKey" "<any random string, 32+ characters>"
dotnet runThat's it for both halves: in Development, the API automatically starts the Angular dev
server (npm start in client/) as an independent child process the first time - see
DevTools/SpaLauncher.cs. It checks port 4200 first, so restarting the API (or
dotnet watch run reloading on every save) never spawns a duplicate. Set
AUTHGUARD_NO_SPA=1 to skip this and run the client yourself. Note: this cleans up the
child process on a normal stop (Ctrl+C), but a hard kill (Task Manager "End Task",
Stop-Process -Force) bypasses .NET's shutdown handlers and can leave it orphaned -
just stop the stray node process manually if that happens.
GET /health reports whether this instance can reach its own database - public,
unauthenticated, and meant for a load balancer or orchestrator to poll, the way any
infra health probe works. It answers {"status":"Healthy","checks":[...]} (200) or
Unhealthy (503), never anything about the application's data or users.
Swagger UI opens at https://localhost:<port>/swagger in development - every endpoint
is testable from there, including pasting a bearer token in for the protected ones.
Each one carries a real description, not just its method signature: what it does, the
security reasoning behind non-obvious behaviour (why /register and /login fail
differently, what requiresMfa: true means, why /mfa/disable re-asks for the
password), and every status code it can actually return. That comes from ordinary XML
doc comments (<summary>/<remarks>/<response>) on the controllers and DTOs,
wired into Swashbuckle via IncludeXmlComments - nothing sits in a separate doc site
that can drift out of sync with the code.
CORS is open to http://localhost:4200 in development only. UseHttpsRedirection() is
skipped in Development on purpose (see the comment above it in Program.cs): the
Angular proxy targets the API's http endpoint, and if that redirects to https, the
browser follows the redirect itself - a cross-origin hop from its perspective, which
strips the Authorization header per the Fetch spec. Every authenticated request would
then silently arrive with no token and 401, while login/register (which need no token)
looked fine - which is exactly the bug this caused the first time the API ran under the
https launch profile (e.g. Visual Studio's default F5).
dotnet test # from the repo root, or from tests/AuthGuard.Tests56 tests: 43 unit tests against AuthService, and 13 integration tests that start the
real API in-process (WebApplicationFactory<Program>) and drive it over HTTP. The
integration set exists because the interesting failures at that layer are
configuration failures - a role name typo, a claim-type mismatch, a rate-limiting
policy that is silently not applied - and every one of those can be wrong while all 43
unit tests stay green. They run against SQLite in-memory rather than EF's InMemory
provider, so unique indexes and migrations behave as they really do.
The client has its own suite (npm test in client/) covering the HTTP interceptor
and the route guards - the only parts of the front end with logic worth testing.
Client: started automatically by the API (see above). First time only, install its dependencies before running the API:
cd client
npm installOr run it yourself in a second terminal instead (npm start - http://localhost:4200).
The browser only ever talks to the client's own origin: proxy.conf.json forwards
/api/* calls to http://localhost:5028 (the API's default http launch profile
port) server-side, inside the Angular dev server - so there's no cross-origin request
and no CORS involved in dev at all. If your API runs on a different port, update the
target in client/proxy.conf.json (not environment.ts - apiBaseUrl is
deliberately empty/relative for exactly this reason: a hardcoded port here is what
caused an ERR_CONNECTION_REFUSED the first time this was wired up with a mismatched port).
The very first Admin account still has to come from somewhere with no Admin yet to
grant it - there is deliberately no self-service "become Admin" endpoint reachable once
one exists. What handles that bootstrap moment is GET /api/auth/setup-status: while
_db.Users.AnyAsync(u => u.Role == "Admin") is false, the client redirects /login and
/register straight to /setup, a one-time "create the first Admin account" form
that calls POST /api/auth/setup-first-admin - the exact same account-creation path as
/register (same password rules, same breach check, same verification email), just
with the Admin role and refused outright the moment any Admin already exists (checked
again on that call itself, not just trusted from the client's last status check). Once
that first account exists, /setup stops being reachable at all - setupCompleteGuard
redirects it to /login - and Users in the nav bar (PATCH /api/admin/users/{id}/role, [Authorize(Roles = "Admin")]) is how every promotion or
demotion happens from then on.
Direct SQL is still there as a fallback for a database that already has users but somehow lost its only Admin (a botched manual edit, restoring from an old backup):
UPDATE Users SET Role = 'Admin' WHERE Username = 'yourusername';then log out and back in (or wait for a token refresh) so the new JWT carries the updated role claim.
To try the MFA flow end to end: Security in the nav bar -> Enable MFA -> scan the QR code with any authenticator app -> enter a code. The ten recovery codes appear once, on their own screen. Copy one, log out, and sign back in using that code in place of the six-digit one - it will work exactly once. Disable MFA on the same page asks for the account password before it will turn the second factor off.
Route guards are UX, not enforcement. authGuard/adminGuard just hide pages and
redirect - a user could always call the API directly with dev tools. The real gate is
the server's [Authorize]/[Authorize(Roles = ...)], which is why the audit-log page
handles a 403 from the API gracefully instead of assuming the guard already covered it.
The refresh token lives in an httpOnly cookie, not localStorage. AuthController
sets it as httpOnly, Secure, SameSite=Strict, scoped to /api/auth - client-side
JavaScript, including this app's own code, has no way to read it at all. That's what
actually closes the XSS-theft gap a localStorage refresh token has: login(),
refresh() and logout() in auth.service.ts pass withCredentials: true so the
browser attaches whatever cookie it's already holding, and the response body never
contains the token's value ([JsonIgnore] on AuthResponse.RefreshToken). Only the
short-lived access token is kept in localStorage, for the same reason it always was:
the dashboard needs to decode it client-side to render the UI, and its 15-minute
lifetime bounds how much a theft of it is worth.
Recovery codes exist only in the browser's memory, once. The confirm response is the only time the plaintext codes are ever transmitted; only hashes are stored, so no later API call can retrieve them. The UI puts them on their own screen with an explicit acknowledgement step rather than a toast, because a user who scrolls past them has lost them for good.
JWT decoding client-side is for display only. The dashboard reads the username/role out of the JWT payload to render the UI, but never verifies the signature - that check only ever happens server-side, so a tampered token simply gets rejected by the API regardless of what the client displays.
The things below are real weaknesses. They are listed rather than hidden because knowing where a design gives ground is the actual skill - and because every one of them is a fair question to ask me about this code.
An access token outlives a logout, a lockout, and a session revocation. This is
inherent to stateless JWTs: revoking refresh tokens revokes the ability to renew, not
the token already in the user's hands. So for up to 15 minutes after a logout, an
account lockout, or a reuse-detection wipe, an already-issued access token still works.
Fifteen minutes is the mitigation. Closing it properly means a denylist of jti values
in a shared cache, checked on every request - which trades away the statelessness that
is the whole reason to use JWTs, and is only worth it once you actually need it.
Registration still tells you whether a username is taken, on purpose.
POST /api/auth/register answers 409 immediately for a taken username - a real, if
minor, enumeration surface, accepted because a username is a public handle the
registrant is choosing, and immediate feedback on it is standard signup UX. The email
side of that same oracle is closed (see "Email verification" above): a taken email gets
the identical success response as a fresh registration. The login flow reveals
nothing at all: same message, same status code, same response time. The one exception
there is the 423 Locked response, which does confirm an account exists; that is the
standard trade for being able to tell a real user why they cannot get in.
SQLite is the default, wrong for anything with concurrent writers, and PostgreSQL is
wired up but genuinely untested here. Database:Provider (default "Sqlite") picks
the EF Core provider - set it to "Postgres" and point ConnectionStrings:Default at
a real server to use Npgsql.EntityFrameworkCore.PostgreSQL instead. That switch is
the honest extent of it, though: nothing in this repo - no test, no CI job - ever runs
against a real Postgres instance, and the Migrations/ folder was generated with
UseSqlite active, so its column types (TEXT/INTEGER SQLite affinities) won't
apply cleanly to Postgres as-is. Using this for real means regenerating migrations
with UseNpgsql active first: set Database:Provider to Postgres, delete
Migrations/, and run dotnet ef migrations add InitialCreate again before the first
dotnet run against it. DatabaseProviderConfigurator is a pure function specifically
so which provider gets configured is unit-tested (4 tests, checking
Database.ProviderName without ever opening a connection) even though actually talking
to Postgres isn't. Database.Migrate() at startup is the same story either way -
convenient for a demo, a race between instances in a real multi-instance deployment.
MIT - see this repo for the code; do reach out if you have questions about a design decision, that's exactly what it's for.