Authentication and Authorization
By the end of this lesson
Protect routes and vary UI by permission.
Two words that get used interchangeably and must not be. Authentication is finding out who somebody is. Authorization is deciding what that person is allowed to do. A signed-in user is authenticated; whether they may see a salary is a separate question with a separate answer.
How the sign-in happens is outside this lesson: an identity provider, ASP.NET Core Identity, cookies issued by the host application. What all of them end up providing is the same thing — an AuthenticationStateProvider that can tell Blazor who the current user is, expressed as a ClaimsPrincipal.
The rest of the lesson is about what components do with that, and about one line you should read twice: hiding a button is presentation. It is not enforcement. Everything else here is mechanics; that distinction is the part that costs people money when they get it wrong.
// Program.cs — current .NET registers the cascade as a service
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("ViewSalary", policy =>
policy.RequireClaim("permission", "view-salary"));
});
@* Components/Routes.razor *@
<Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData">
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(MainLayout)">
<Authorizing>
<p role="status">Checking your access…</p>
</Authorizing>
<NotAuthorized>
@if (context.User.Identity?.IsAuthenticated is true)
{
<p role="alert">
You do not have access to this page. Contact the IT service
desk if you think you should.
</p>
}
else
{
<RedirectToSignIn />
}
</NotAuthorized>
</AuthorizeRouteView>
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>- AddCascadingAuthenticationState makes the current user available to every component as a cascading value, without any component having to inject anything. Older projects do the same job by wrapping the router in a <CascadingAuthenticationState> component; the registration is the current form and the effect is identical.
- AuthorizeRouteView replaces RouteView. It renders the page when the visitor is allowed to see it, and one of its own fragments when they are not.
- The two failure cases are genuinely different and the markup separates them. Not signed in means send them to sign in. Signed in but not permitted means say so plainly, because sending that person to a sign-in page produces a loop they cannot escape.
- The Authorizing fragment covers the gap while the answer is being worked out. Leave it out and the page can flash "you do not have access" before the check completes, and people believe the first thing they read.
- A policy is registered once and named. "ViewSalary" survives a reorganisation; a role name written into fifteen components does not.
- RedirectToSignIn is a small component you write — a few lines that navigate to your sign-in address with a return path. Nothing built in knows where your sign-in page is.
- FocusOnNavigate moves keyboard focus to the new page's heading. Without it, a keyboard or screen reader user stays where they were and has no idea the page changed.
@page "/employees/{Id:int}/salary"
@attribute [Authorize(Policy = "ViewSalary")]
@rendermode InteractiveServer
<h1>Salary</h1>
<AuthorizeView Policy="EditSalary">
<Authorized Context="auth">
<p>Signed in as @auth.User.Identity?.Name</p>
<button type="button" @onclick="StartEdit">Edit salary</button>
</Authorized>
<NotAuthorized>
<p>You can view this record. Changing it needs an HR administrator.</p>
</NotAuthorized>
</AuthorizeView>
@code {
[Parameter]
public int Id { get; set; }
private void StartEdit() { /* opens the edit form */ }
}- @attribute [Authorize] applies the attribute to the generated component class. The router checks it before rendering, so an unauthorised visitor sees the NotAuthorized content from the previous section instead of this page.
- [Authorize] with no arguments means "signed in". Adding Policy or Roles narrows it from there.
- AuthorizeView renders one of its two fragments according to the answer. It does not guard the route — it is inside a page that is already guarded, deciding what to show within it.
- Two levels of permission on one screen: viewing is enforced by the route, editing changes what is rendered. That pairing is common and worth recognising, because it is where the next callout applies.
- Context="auth" names the fragment's context so the markup does not rely on the implicit name. Inside it, auth.User is a ClaimsPrincipal — the same type you would have on the server.
- The NotAuthorized branch explains rather than leaving a gap. A button that is absent with no reason reads as a broken page to whoever expected it.
- There is an Authorizing fragment here too, and it matters when the check involves an async call. Without it, the view shows the not-authorised branch first and then corrects itself, which is worse than showing nothing.
@inject AuthenticationStateProvider AuthProvider
@code {
[CascadingParameter]
private Task<AuthenticationState>? AuthenticationStateTask { get; set; }
private bool canApproveLeave;
protected override async Task OnParametersSetAsync()
{
if (AuthenticationStateTask is null)
{
return;
}
var state = await AuthenticationStateTask;
var user = state.User;
canApproveLeave =
user.Identity?.IsAuthenticated is true
&& user.HasClaim("permission", "approve-leave");
}
private async Task<string> DescribeCurrentUserAsync()
{
var state = await AuthProvider.GetAuthenticationStateAsync();
return state.User.Identity?.Name ?? "not signed in";
}
}- The cascading value is a Task<AuthenticationState>, not a finished state object, because the answer may not have arrived yet. Awaiting it is how a component says "I need this before I decide".
- It is nullable because a component rendered outside the cascade never receives it. Treating a missing value as "not permitted" is the safe reading, which is what the early return does here.
- HasClaim asks a question without assuming the claim is present. Indexing into claims and reading .Value throws for anyone signed in through a provider that issues a different set.
- OnParametersSetAsync rather than OnInitializedAsync, because the cascading value can be supplied again and a component is reused when its parameters change.
- AuthenticationStateProvider is the service behind the cascade. Inject it when you need the user outside the render flow, or in a service a component calls. It also raises a notification when the user signs in or out, which is what lets AuthorizeView update without a page reload.
- Prefer the cascading Task inside components. Calling the provider on every render works and does more work than it needs to.
The pieces, and which question each one answers:
- AuthenticationStateProvider
- The service that answers "who is the current user?" and raises a notification when that answer changes. Every sign-in mechanism ends up behind one of these.
- AddCascadingAuthenticationState
- Makes the provider's answer available to every component as a cascading Task<AuthenticationState>, so nothing has to inject the provider. Older projects use the <CascadingAuthenticationState> component for the same purpose.
- AuthorizeRouteView
- Checks a page's [Authorize] attribute before rendering it, and supplies the Authorizing and NotAuthorized fragments for the two cases where the page is not shown.
- AuthorizeView
- Chooses between fragments inside a page. Presentation only — it changes what is rendered and nothing about what the server will accept.
- [Authorize] on a component
- Applied with @attribute, and checked by the router. It guards routable components. A component rendered inside a page is not checked, whatever attributes it carries.
- ClaimsPrincipal
- The user, as a set of claims. Identity?.IsAuthenticated answers authentication; IsInRole and HasClaim answer authorization; a registered policy wraps that decision in a name.
Summary
- Authentication answers who the user is; authorization answers what they may do, and the two need separate decisions
- AddCascadingAuthenticationState makes the current user available to every component as a Task<AuthenticationState>
- [Authorize] applied with @attribute guards routable components, and AuthorizeRouteView supplies the Authorizing and NotAuthorized content
- AuthorizeView varies what is rendered inside a page, and named policies keep role strings out of markup
- Hiding UI is presentation: the server must authorise every operation, and in WebAssembly all client-side logic is readable by the user
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Think about it
Where does the check belong?
Leave requests can be approved by managers only. Four candidate places for the rule: hide the Approve button with AuthorizeView, render it disabled instead, check permission inside the component's click handler, check permission in the API endpoint that records the approval.
Which are necessary, which are optional, and which one would you refuse to ship without?
Show solution
The endpoint check is the only one that is not optional. It is the last point before the change is made, it is on a server the user does not control, and it is the only one of the four that cannot be bypassed. If you ship one check, it is that one.
Hiding the button is worth doing for a different reason. Showing someone an action that will be refused wastes their time and generates support requests. It is a courtesy, and describing it as security is where teams get into trouble.
The handler check is defence in depth and cheap to add. It catches a component rendered in the wrong place, and it makes the intent explicit to the next person reading the file. It is not a substitute for the endpoint check, because the handler runs where the caller has influence — entirely so in WebAssembly.
Disabled versus hidden is a usability and accessibility judgement, not a security one. A disabled control with no explanation is confusing, and browsers leave disabled buttons out of the tab order, so a keyboard user may never encounter the thing that would have explained itself. If you disable it, say why in text near it. If the action is irrelevant to that person, hiding it is kinder than a permanently dead control.
The shape worth remembering: enforce on the server, reflect in the UI, and never let the second one stand in for the first.
Try it yourself
Check your own enforcement
On your own development machine, take a screen where an action is hidden behind AuthorizeView. Sign in as a user without that permission, then call the endpoint the hidden button would have called — using the API's own generated test page, or a request from your terminal with that user's session.
Record what the server does. Then fix whatever needs fixing and run the same check again.
Show solution
If the call succeeds, the permission existed only in the markup. That is the finding worth having, and it is better found by you on a development machine than by anyone else anywhere else.
The fix belongs in the endpoint or the service behind it: an [Authorize] attribute with the policy, or an explicit check before the write. Where the rule depends on the record — a manager may approve for their own team only — the attribute cannot decide it, so the check goes in the handler with the record loaded.
Repeat the check after the fix and expect a 403. A 401 would mean the request was not recognised as signed in at all, which is a different problem and worth not confusing with this one.
Make this a habit rather than an exercise. Every time you hide something in the UI, ask what happens when the request arrives anyway, and confirm it rather than assuming. The confirmation takes a minute and the assumption has no upper bound on what it costs.
// The check that counts, on the server, with the record in hand
app.MapPost("/api/leave/{id:int}/approve", async (
int id,
ClaimsPrincipal user,
ILeaveService leave,
CancellationToken token) =>
{
var request = await leave.GetAsync(id, token);
if (request is null)
{
return Results.NotFound();
}
if (!await leave.CanApproveAsync(user, request, token))
{
return Results.Forbid();
}
await leave.ApproveAsync(request, user, token);
return Results.NoContent();
})
.RequireAuthorization("ApproveLeave");Knowledge check
Nothing is recorded and there is no score. The explanation appears either way.
Saved in this browser only.