Tutorials / Build a REST API with C# and .NET · Lesson 37 of 38

Backend

On this page

    Protect Endpoints and Roles

    The API hands out tokens since the login endpoint, and nobody looks at them. bob can read the stock report with a token, without one, or with a token he made up. In the badge story from what a JWT is, reception prints badges and the doors have no guards. This lesson adds the guards.

    Our goal: the API checks the token on every request. The books are for anyone who has logged in, and the stock report is for managers only.

    Here is the route:

    1. Teach the API to read a token and check its stamp.
    2. Lock the books endpoints, so only logged-in users get in.
    3. Lock the report for the Manager role.
    4. Run five requests, one at a time, and meet the two refusals: 401 and 403.
    5. See what this does to the web pages (they stop working, for now).
    6. See why the unit tests did not notice, and what checks the locks instead.

    Left out: the web pages logging in, which is the login screen. Also left out: rules that need more than a role (ASP.NET calls them policies), and a test that sends real requests, which comes later in the course.

    Where we are

    This is the solution from the end of the login endpoint. No file is added:

    BookShop/
      src/
        BookShop.Api/
          Program.cs                         <- changes
          Controllers/
            BooksController.cs               <- changes (one line and a using)
            ReportsController.cs             <- changes (one line and a using)
    

    Only the files that change are listed. Run every command from the solution folder, BookShop/. No project is added, so there is no solution map.


    Two guards on the way in

    Every request passes a row of steps before it reaches a controller, in the order Program.cs lists them (the global error handler was one of them, in errors the professional way). We add two steps, one for each question from the JWT lesson:

    Authentication, then authorization, then the controller Request Static files the pages, for anyone Authentication who are you? reads the token Authorization may you? reads the lock Controller 401 no good token 403 wrong role
    • Authentication looks for an Authorization: Bearer ... header, checks the token's stamp, issuer, audience and expiry, and, when all is well, remembers the user and their claims for the rest of the request. It does not refuse anything by itself.
    • Authorization looks at the endpoint. If the endpoint has no lock, the request goes through. If it has a lock, it compares the lock with the user from the step before, and refuses with 401 (we do not know who you are) or 403 (we know, and you may not).

    The pages in wwwroot are handed out before both steps, so they stay open to anyone. That is on purpose, and it is safe: a page is only an empty table and a script. The data comes from the API, and the API checks every request for it.


    Teach the API to check a token

    Open src/BookShop.Api/Program.cs. The whole file is:

    using System.Text;                                                                  // new
    using BookShop.Data;
    using Microsoft.AspNetCore.Authentication.JwtBearer;                                // new
    using Microsoft.EntityFrameworkCore;
    using Microsoft.IdentityModel.Tokens;                                               // new
    using Scalar.AspNetCore;
    
    var builder = WebApplication.CreateBuilder(args);
    
    builder.Services.AddControllers();
    builder.Services.AddOpenApi();
    builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
    builder.Services.AddProblemDetails();
    
    var connectionString = builder.Configuration.GetConnectionString("BookShop");
    builder.Services.AddDbContext<BookShopDbContext>(options => options.UseSqlite(connectionString));
    builder.Services.AddScoped<IBookService, BookService>();
    builder.Services.AddScoped<IStockReportService, StockReportService>();
    
    var jwtSettings = builder.Configuration.GetSection("Jwt").Get<JwtSettings>()!;
    builder.Services.AddSingleton(jwtSettings);
    builder.Services.AddSingleton<TokenService>();
    builder.Services.AddSingleton<IUserService, DemoUserService>();
    
    builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)          // new
        .AddJwtBearer(options =>                                                        // new
        {                                                                               // new
            options.TokenValidationParameters = new TokenValidationParameters          // new
            {                                                                           // new
                ValidIssuer = jwtSettings.Issuer,                                       // new
                ValidAudience = jwtSettings.Audience,                                   // new
                IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings.Key))   // new
            };                                                                          // new
        });                                                                             // new
    builder.Services.AddAuthorization();                                                // new
    
    var app = builder.Build();
    
    app.UseExceptionHandler();
    app.UseStatusCodePages();
    
    if (app.Environment.IsDevelopment())
    {
        app.MapOpenApi();
        app.MapScalarApiReference();
    }
    
    app.UseHttpsRedirection();
    
    app.UseDefaultFiles();
    app.UseStaticFiles();
    
    app.UseAuthentication();                                                            // new
    app.UseAuthorization();                                                             // new
    
    app.MapControllers();
    
    app.Run();
    

    The new lines, top to bottom:

    • AddAuthentication(JwtBearerDefaults.AuthenticationScheme) registers authentication, with one way of finding out who the user is. ASP.NET calls such a way a scheme. JwtBearerDefaults.AuthenticationScheme is simply the name "Bearer": the user is whoever the Authorization: Bearer token says.
    • AddJwtBearer sets that scheme up. TokenValidationParameters lists what a good token must have, and they are the same values the token service used to make it: the issuer, the audience, and the key. The guard checks the stamp with the same key that reception used to make it. This is why the settings object was kept in a variable in the last lesson.
    • The expiry is not in the list because it is checked by default. One detail: the check allows five minutes of difference, in case two servers' clocks disagree, so a token is refused up to five minutes after its exp.
    • The role is not in the list either. .NET recognises a claim called role as the user's role by itself, so "role": "Staff" in the payload is all it needs.
    • AddAuthorization() registers the step that reads the locks.
    • UseAuthentication() and UseAuthorization() put the two steps in the row, in that order: first find out who, then decide. They sit after the static files, so the pages stay open, and before MapControllers(), so every controller is behind them.

    Nothing is locked yet. A request with no token passes both steps, because no endpoint asks for anything. Locks come next.


    Lock the books

    A lock is an attribute, [Authorize]: "only for users that authentication recognised". Put on the controller class, it locks every endpoint in it.

    Open src/BookShop.Api/Controllers/BooksController.cs. Only the top of the file changes; everything from the constructor down is exactly as it was at the end of DTOs for the books endpoints:

    using System.ComponentModel.DataAnnotations;
    using BookShop.Data;
    using Microsoft.AspNetCore.Authorization;                     // new
    using Microsoft.AspNetCore.Mvc;
    using Microsoft.AspNetCore.Mvc.ModelBinding;
    
    [Authorize]                                                   // new
    [ApiController]
    [Route("books")]
    public class BooksController : ControllerBase
    {
        // ... unchanged ...
    

    Staff and managers both work with books, so the lock asks for no role, only a logged-in user.

    💡 Some teams turn this round: every endpoint is locked unless it says otherwise (ASP.NET calls it a fallback policy), and the few open ones carry [AllowAnonymous]. It is safer when there are many controllers, because a forgotten attribute leaves an endpoint locked rather than open. With three controllers, we write the locks where they are.


    Keep the report for managers

    The report gets a lock with a role. Open src/BookShop.Api/Controllers/ReportsController.cs. The whole file is:

    using BookShop.Data;
    using Microsoft.AspNetCore.Authorization;                     // new
    using Microsoft.AspNetCore.Mvc;
    
    [Authorize(Roles = "Manager")]                                // new
    [ApiController]
    [Route("reports")]
    public class ReportsController : ControllerBase
    {
        private readonly IStockReportService _reportService;
    
        public ReportsController(IStockReportService reportService)
        {
            _reportService = reportService;
        }
    
        [HttpGet("stock")]
        public async Task<ActionResult<List<CategoryStock>>> GetStock()
        {
            var report = await _reportService.GetByCategoryAsync();
            return report;
        }
    }
    

    Roles = "Manager" means: a logged-in user whose role claim is Manager. Several roles can share a lock, separated by commas ("Manager,Owner" lets in either).

    And the login endpoint? AuthController has no attribute, so it stays open. It has to: you cannot ask for a token while you need a token to ask.


    Run it

    Start the app. To send a token, you need one first. Log in as bob, as in the last lesson:

    curl -X POST http://localhost:5101/auth/login -H "Content-Type: application/json" -d '{"userName":"bob","password":"bob-pass-1"}'
    

    Copy the token from the answer (the long text between the quotes after "token":) and keep it in a shell variable, so you do not paste 240 characters into every command. In bash (macOS, Linux, Git Bash):

    TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJCb29rU2hvcCIs...
    

    In PowerShell, the same line is $TOKEN = "eyJhbGci...", with the quotes. In both, $TOKEN in a later command is replaced by the token. The token lives for an hour; after that, log in again and set the variable again.

    Each case below is one request, run on its own.

    Case A: no token

    curl -i http://localhost:5101/books
    
    HTTP/1.1 401 Unauthorized
    Content-Type: application/problem+json; charset=utf-8
    WWW-Authenticate: Bearer
    
    {
      "type": "https://tools.ietf.org/html/rfc9110#section-15.5.2",
      "title": "Unauthorized",
      "status": 401,
      "traceId": "00-6106c29491f3becb293c90efbce80265-9f458003f7515cd8-00"
    }
    

    (The bodies are one line in reality, and the Date, Server and Transfer-Encoding lines are left out from here on.) No badge, no entry. The new header WWW-Authenticate: Bearer is the API saying how to get in: send a bearer token. The body is Problem Details, like every other error, because UseStatusCodePages() from the errors lesson fills in an empty 401.

    Case B: bob's token, on the books

    curl -i http://localhost:5101/books -H "Authorization: Bearer $TOKEN"
    
    HTTP/1.1 200 OK
    Content-Type: application/json; charset=utf-8
    
    [{"id":2,"title":"Clean Code","author":"Robert C. Martin","price":34.50,"stock":7,"category":"Programming"}, ...]
    

    The five books, as before (shortened here to the first). -H adds a header to the request, in the form from the JWT lesson. The token's stamp is good, so authentication recognised bob, and [Authorize] asks for nothing more.

    Case C: bob's token, on the report

    curl -i http://localhost:5101/reports/stock -H "Authorization: Bearer $TOKEN"
    
    HTTP/1.1 403 Forbidden
    Content-Type: application/problem+json; charset=utf-8
    
    {
      "type": "https://tools.ietf.org/html/rfc9110#section-15.5.4",
      "title": "Forbidden",
      "status": 403,
      "traceId": "00-111d22e263c9d859c797d3c492793420-fc212ca84903af0b-00"
    }
    

    Same token, other door. The API knows this is bob, and bob's role is Staff. 403 Forbidden: "I know who you are, and you may not". Logging in again would not help, so a client should not send bob back to the login page; it should tell him the page is not for him.

    Case D: alice's token, on the report

    Log in as alice ({"userName":"alice","password":"alice-pass-1"}) and put her token in the variable. Then:

    curl -i http://localhost:5101/reports/stock -H "Authorization: Bearer $TOKEN"
    
    HTTP/1.1 200 OK
    Content-Type: application/json; charset=utf-8
    
    [{"category":"Cooking","titles":2,"copies":6,"stockValue":181.0},{"category":"Programming","titles":3,"copies":22,"stockValue":852.3}]
    

    The report, with the numbers from the stock report. alice's role is Manager, so the lock lets her through.

    Case E: bob changes his badge

    This is bob with his pen. We took bob's token, decoded the middle piece, changed "role":"Staff" to "role":"Manager", encoded it again, and put the three pieces back together with the original signature. The middle piece changed, the first and last did not:

    eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJCb29rU2hvcCIsImlzcyI6IkJvb2tTaG9wLkFwaSIsImV4cCI6MTc5MzQ0NDQ5MywibmFtZSI6ImJvYiIsInJvbGUiOiJNYW5hZ2VyIiwiaWF0IjoxNzkzNDQwODkzLCJuYmYiOjE3OTM0NDA4OTN9.BSwmCnwDxkuvfNwkA0JQxEHD5fJBsUS9HjYQvKThbL4
    

    Its payload now reads:

    {"aud":"BookShop","iss":"BookShop.Api","exp":1793444493,"name":"bob","role":"Manager","iat":1793440893,"nbf":1793440893}
    

    We put this edited token in the TOKEN variable and sent it to the report:

    curl -i http://localhost:5101/reports/stock -H "Authorization: Bearer $TOKEN"
    
    HTTP/1.1 401 Unauthorized
    Content-Type: application/problem+json; charset=utf-8
    WWW-Authenticate: Bearer error="invalid_token", error_description="The signature key was not found"
    

    The body is the same 401 Problem Details as in case A. The API computed the stamp again for the new payload, and it did not match. So authentication refused the whole token, and the request counts as having no user at all, hence 401 and not 403. The error_description is worded oddly: it means "none of my keys gives this signature". We also changed just one letter of the middle piece of a good token, and got a 401 again.

    401 and 403, side by side

    401 against 403 401 Unauthorized no token, a bad stamp, or past its time "Who are you?" Log in. 403 Forbidden a good token, the wrong role "Not for you." Logging in won't help.

    The names are historic and confusing: 401 says "Unauthorized" but is about authentication, and 403 is the one about authorization. Remember them by the fix. 401: get a (new) token. 403: nothing the user can do.

    In the test page

    The Scalar page still lists every endpoint, and the locked ones now answer 401. To try them there, add a header named Authorization with the value Bearer and the token to the request before sending it. We did not check the exact clicks. (A test page can also show a login box for the whole page, if the OpenAPI document describes the token; we leave that out.)


    What the pages do now

    Open http://localhost:5101/ in the browser. The page loads, the Books heading is there, and the list is empty under one word: Unauthorized. The page asked for /books without a token, got case A, and showProblem printed the title. The report page says Could not load the report. The API answered 401.

    This is the lock working: the pages have never logged in. The next lesson gives them a login page and teaches them to send the token.


    Test it

    Run the tests:

    dotnet test
    
    Passed!  - Failed:     0, Passed:    27, Skipped:     0, Total:    27, Duration: 842 ms - BookShop.Api.Tests.dll (net10.0)
    

    The same 27 as before, and none of them failed. Why not, when every books endpoint is now locked? The controller tests build the controller themselves, new BooksController(_fakeService), and call its methods directly. No request is involved, so the authentication and authorization steps never run, and [Authorize] is just a label that nobody reads. It is the same reason the validation 400 was never unit-tested: the gate stands in front of the controller, and a unit test starts behind it.

    So the locks are checked by real requests. For now, the five cases above are your checklist, run by hand. A test that sends real HTTP requests to the app, and can check every lock automatically, comes later in the course.


    Where we ended

    Compare with your solution:

    BookShop/
      src/
        BookShop.Api/
          Program.cs                         checks tokens: AddAuthentication, AddJwtBearer, the two steps
          Controllers/
            AuthController.cs                open: anyone may log in
            BooksController.cs               [Authorize]: any logged-in user
            ReportsController.cs             [Authorize(Roles = "Manager")]: managers only
    

    The doors have guards. Every request for data must show a good token, and the report needs the right role.


    Recap

    • Authentication reads the Authorization: Bearer token, checks its stamp, issuer, audience and expiry, and remembers the user. Authorization compares that user with the lock on the endpoint.
    • AddAuthentication and AddJwtBearer set up the check with the same issuer, audience and key that made the token. UseAuthentication() and UseAuthorization() put the two steps in the row, after the static files and before the controllers.
    • [Authorize] locks an endpoint for logged-in users. [Authorize(Roles = "Manager")] also asks for a role. An endpoint without the attribute stays open, like the login.
    • 401: no token, or a bad one; log in. 403: a good token with the wrong role; logging in again does not help.
    • A token changed by hand fails the signature check and counts as no token: 401.
    • The pages stay public; they hold no data. The data is behind the locks.
    • Unit tests call the controller directly and never meet the locks. Real requests check them.

    Questions to think about

    Each question has its answer folded away underneath. Guess first, then open it to check.

    1. What is the difference between a 401 and a 403?
    Show answer

    A 401 means the API does not know who you are: no token, or a bad one. A 403 means it knows exactly who you are, and your role is not allowed in.

    1. Why does the API need the same key to check a token as to make it?
    Show answer

    Checking means making the signature again from the header and payload and comparing. Only the same key gives the same signature.

    1. bob changed his role to Manager in his token. Why did he get a 401 and not a 403?
    Show answer

    The signature no longer matched, so the API threw the whole token away. Without a trusted token there is no user at all, and that is a 401.

    1. Every books endpoint is locked now, yet the controller tests still pass. Why?
    Show answer

    The tests call the controller's methods directly, so no request goes through authentication and authorization. Only a real request meets the locks.

    1. Anyone can still open /report.html. Is that a hole?
    Show answer

    No. The page is only an empty table and a script. The numbers come from /reports/stock, and that request is locked for managers.

    Next lesson: the login screen, where the pages log in, keep the token and send it with every request.

    ← The Login EndpointThe Login Screen →