With the sign-in from 0301, the SPA carries a token. This lesson asks who can read it and who can call the API from foreign pages: CORS on the API, Angular's sanitizing against XSS, the Content Security Policy, XSRF and the dependencies.
The part of the Kladde: the CORS policy in Kladde.Api, a note view in src/app/security and angular.json.
State of the checks. Executed (Node 24.22.3, Angular 22.2, .NET 10.0.400): the probe API with a CORS policy ran locally, the preflight requests below are real curl runs, and their headers are shown unchanged. The XSS tests ran with Vitest and jsdom (
ng test --no-watchexit code 0, 3 tests in 2 files).ng buildwithsecurity.autoCspended with 0, and the generatedindex.htmlwas read (hash shortened).npm audit: exit code 0, "found 0 vulnerabilities";npm ci --dry-runexit code 0. Not executed: a browser (so also no real CORS error in the console, no execution ofonerror), a CSP with a nonce, Trusted Types, XSRF with a cookie sign-in. Statements about Angular come from angular.dev (page "Security", workspace configuration), about CORS from Learn ("Enable Cross-Origin Requests (CORS) in ASP.NET Core"), read on the day of writing.
CORS: only when the origin changes
An origin consists of scheme, host and port. http://localhost:4200 and https://localhost:7180 are two origins. The browser does not allow a page to read the response of another origin (same-origin policy); CORS is the permission the server gives for that.
Two statements of the page first: CORS is not a security feature but relaxes a security rule ("An API is not safer by allowing CORS"), and the server runs the request anyway: it is the browser that discards the response. curl or a .NET client are not affected.
The SPA from 0101 calls /api/... relatively, and the dev server forwards through the proxy: for the browser this is the same origin, and CORS does not come up. It becomes necessary as soon as the SPA calls its API at a different address, for example when SPA and API are separate in Azure (0403)
or when you call https://localhost:7180 in the dev server without the proxy.
The policy
var origins = builder.Configuration.GetSection("Cors:Origins").Get<string[]>() ?? [];
builder.Services.AddCors(options => options.AddPolicy("kladde-spa", policy => policy
.WithOrigins(origins)
.WithHeaders("Authorization", "Content-Type")
.WithMethods("GET", "POST", "PUT", "DELETE")));
var app = builder.Build();
app.UseRouting();
app.UseCors("kladde-spa");
app.UseAuthentication();
app.UseAuthorization();
appsettings.json contains "Cors": { "Origins": [ "http://localhost:4200" ] }. The rules of the page: the origin has no slash at the end, otherwise the comparison gives false and no header comes back; UseCors comes after UseRouting and before UseAuthorization.
In the probe it also comes before UseAuthentication: there is a reason for that, see the runs below. According to the page, AllowAnyOrigin is insecure, and together with AllowCredentials an insecure setting; the Kladde needs neither, because the token travels in the Authorization header, not as a cookie.
The preflight request with Bearer
Before the actual request, the browser asks with OPTIONS if the request is not "simple": the page gives as the condition that no headers other than Accept, Accept-Language, Content-Language, Content-Type and Last-Event-ID are set
(and a method of GET, HEAD or POST). An Authorization header falls outside: every call of the Kladde API with a token triggers a preflight request. The runs against the probe at http://localhost:5188, each curl.exe -i -X OPTIONS with the headers named:
# A: Origin http://localhost:4200, Access-Control-Request-Headers: authorization
HTTP/1.1 204 No Content
Access-Control-Allow-Headers: Authorization,Content-Type
Access-Control-Allow-Methods: GET,POST,PUT,DELETE
Access-Control-Allow-Origin: http://localhost:4200
# B: Origin http://localhost:4300 (wrong origin)
HTTP/1.1 204 No Content
(no Access-Control headers)
# C: Origin http://localhost:4200/ (with slash)
HTTP/1.1 204 No Content
(no Access-Control headers)
The run confirms what the page says: a rejected preflight request still gets 204, just without the CORS headers, and the browser then rejects the call. A wrong origin therefore cannot be recognized by a status code but by the absence of the headers.
A GET without a token with Origin: http://localhost:4200 gave 401 with Access-Control-Allow-Origin and WWW-Authenticate: Bearer: because UseCors comes before authentication, the SPA sees a real 401, not a CORS error. A run with an additionally requested header x-custom gave the same response as A;
the page describes a rejection here. Whether the browser then blocks the call has not been checked without a browser: open.