Authentication and Authorization in MVC
Security questions in a web app split into two that sound alike but are enforced in different places: authentication ("who are you?") and authorization ("what may you do?"). In a public-sector app holding candidate records, getting the second one wrong is the classic audit finding β so this lesson treats authorization as the main event.
Cookie authentication and Identity
The shape is standard: the user proves identity once (password, or the
agency's single sign-on), the server issues an encrypted authentication cookie, and every
subsequent request carries it. In ASP.NET Core this is the built-in cookie authentication
handler, with ASP.NET Core Identity providing user/password storage, hashing, and
lockout on top. The cookie materializes on each request as a
ClaimsPrincipal β a bag of claims (user id, name, roles) available as User in
controllers and views. Practical rules: keep session lifetime short for staff apps, always mark
the cookie Secure and HttpOnly, and never invent your own cookie scheme.
[Authorize], roles, and policies
The [Authorize] attribute is the gate. Applied to a controller or action, it rejects
anonymous users; with arguments it checks who the user is:
[Authorize(Roles = "CommissionAnalyst,Admin")]
public IActionResult ReviewQueue() { ... }
Roles ("Admin", "EppReviewer") are coarse-grained and fine for menu-level access. The
better tool is policies β named requirements like RequireClaim("EppId") or custom
IAuthorizationHandlers, registered once in Program.cs β which keep rules in one place
instead of scattered strings, and compose ([Authorize(Policy = "SameEpp")]). Two habits
matter: authorize by deny-by-default (a global fallback policy requiring
authentication, with [AllowAnonymous] as the explicit exception), and never rely on hiding a
link in the view β the URL still exists.
The check attributes can't do: record ownership
Roles answer "may reviewers open candidate records?" They cannot answer "may this reviewer open this candidate's record?" That's a per-record ownership check, and it must live in your service layer, next to the data:
var candidate = await _db.Candidates.FindAsync(id);
if (candidate is null || candidate.EppId != currentUser.EppId)
return Forbid();
Skipping this is the OWASP Insecure Direct Object Reference bug: a reviewer from one EPP
edits the URL from /Candidates/Details/482 to /483 and reads another program's student
data. Put the ownership filter in the query itself (Where(c => c.EppId == user.EppId))
wherever possible, so forgetting the check returns nothing rather than someone else's record.
Attribute for the door, query filter for the filing cabinet β you need both.