Skip to content

Repository files navigation

AuthGuard

CI

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.

Why this project exists

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.

What it does

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

Security decisions, and why

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.

Architecture

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

Running it

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 run

That'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.Tests

56 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 install

Or 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.

Client-side security notes

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.

Known limitations, deliberately

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.

License

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.

About

From-scratch ASP.NET Core + Angular auth system: Argon2id hashing (with HIBP breach check), JWT + rotating refresh tokens with reuse detection, TOTP MFA with recovery codes, RBAC with a self-service first-admin setup, lockout, rate limiting, and a full audit log.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages