How do you secure a Web API in ASP.NET Core (HTTPS, authentication, CORS, input validation, rate limits, secrets, XSS and CSRF)?
14 reports in the question index: Wipro Infrastructure Engineering (4), Infosys (2), Deloitte, Capgemini, Cencora, ZeMoSo, Unlock Technologies, Corporate Infotech, Driven IT Solutions and one unnamed company, in first technical rounds up to October 2026.
Say this first · about 30 seconds
I secure an API in layers, so one mistake doesn't open everything. The outer layers are HTTPS with HSTS, CORS limited to our own front-end sites, and token authentication, with authorization on by default through a fallback policy, so a new endpoint is protected even if someone forgets the attribute.
Inside the endpoint I check that the record belongs to the caller, because broken object level authorization is the most common hole in APIs.
Then abuse and secrets: rate limiting per user, and per IP address for anonymous calls, answering with a 429 and a Retry-After header, and secrets in , read through a . I can go into CSRF and XSS if you'd like.
Make it yours · Navistar Financial credit applications API (invented example)
Situation: the API calls a with an API key. The key sat in appsettings.Production.json, in the repository, so anyone with read access to the code could use our account, and every pull of the repo copied it.
What I did: moved the key to Azure Key Vault, Azure's locked store for secrets. The has a managed identity: Azure gives the app its own identity, so it signs in to with no password in our config. At start-up, loads the vault's secrets into configuration. The rotated the old key (replaced it with a new one), and secret scanning (a tool that blocks a push containing a key) now stops it happening again.
Result: no secret value in the repository or in settings (only the vault's address, which isn't a secret); the code reads config["Bureau:ApiKey"] exactly as before. Rotating the key is a change in the vault, not a release.
What I'd say: secrets go in behind a , so there's no bootstrap password, and a leaked repository leaks no keys.
The code, simplified
Invented to show the shape, in C#. Tap Next: the API starts up in Azure and reads the key.
Navistar.sln · where these 2 files sit
Bureau/
Diagram · step 1The API starts on . Its settings hold only the vault's address.
APIstarting
Entra IDidentity
Key Vaultsecrets
KeyVault:Uri
Program.cs · line 2 · 1 of 3The vault's address: not a secret, so it sits in plain settings. ! tells the compiler "this is not null".
Fix later in your repoExample written for this card: invented, not from a real codebase.
Say it out loud, then check
Show the 30-second answer
I secure an API in layers, so one mistake doesn't open everything. The outer layers are HTTPS with HSTS, CORS limited to our own front-end sites, and token authentication, with authorization on by default through a fallback policy, so a new endpoint is protected even if someone forgets the attribute.
Inside the endpoint I check that the record belongs to the caller, because broken object level authorization is the most common hole in APIs.
Then abuse and secrets: rate limiting per user, and per IP address for anonymous calls, answering with a 429 and a Retry-After header, and secrets in , read through a . I can go into CSRF and XSS if you'd like.
Check your answer: tick what you said
You said 0 of 8
Then add your example: Navistar Financial credit applications API (invented example)
Situation: the API calls a with an API key. The key sat in appsettings.Production.json, in the repository, so anyone with read access to the code could use our account, and every pull of the repo copied it.
What I did: moved the key to Azure Key Vault, Azure's locked store for secrets. The has a managed identity: Azure gives the app its own identity, so it signs in to with no password in our config. At start-up, loads the vault's secrets into configuration. The rotated the old key (replaced it with a new one), and secret scanning (a tool that blocks a push containing a key) now stops it happening again.
Result: no secret value in the repository or in settings (only the vault's address, which isn't a secret); the code reads config["Bureau:ApiKey"] exactly as before. Rotating the key is a change in the vault, not a release.
What I'd say: secrets go in behind a , so there's no bootstrap password, and a leaked repository leaks no keys.
See it
HTTPS + HSTSencrypted only
GET loans/7
priya, dealer D-204
CORSour dealer sites
Authenticationtoken → user
Rate limiter100 a minute
Authorizationsigned in by default
Validation[ApiController]
Actionis loan 7 hers?
1/11Priya, at dealer D-204, opens loan 7 in the dealer app. Each layer checks one thing.HTTPS: the call is encrypted. A plain http:// call would get a redirect to https://.CORS: the page is on an allowed dealer site, so the browser may read the answer.Authentication: the token checks out and becomes the user: Priya, dealer D-204.Rate limit: Priya's 37th call this minute. The cap is 100, so it goes through.Authorization: the fallback policy needs a signed-in user. She is.Validation: the id is a whole number; bad input would stop here with a 400.The action loads loan 7 with a SQL parameter and checks it is D-204's. It is: 200, a DTO.No token: no user, so the limiter counts the call by IP; then the fallback policy answers 401.Call 101 this minute: the rate limiter answers 429 with Retry-After. Nothing after it runs.Loan 9, another dealer's: every layer passes, but the action's own check fails: 404.
In plain words
Defence in depth: several separate checks, one after another, so that if one is wrong or missing, the next still protects you.
HTTPS: the request travels encrypted, so nobody on the network can read or change it, tokens included. answers a plain http:// call with a redirect to https://.HSTS is a response header that tells the browser "only ever use HTTPS for this site" for a set time.
CORS: the browser rule that says which other websites' pages may read the API's answers (). It protects users in browsers, not the API itself.
Authentication and authorization: who you are, then what you may do (; tokens: ; a JWT is a signed token the client sends with every call). A fallback policy applies to every endpoint that has no [Authorize] or [AllowAnonymous] of its own, so the default is "signed in".
BOLA: a signed-in user reads someone else's record by changing an id. The fix is a per-record check ().
Input validation: checking every value the client sends, on the server (). We send out only DTOs (classes shaped for one response, with just the fields the client may see), never entities (the classes that mirror database tables). SQL injection is input that changes a database query; parameters (values sent apart from the SQL text) stop it, and EF Core uses them for you.
Rate limiting: a cap on how many calls one caller may make in a time window, so one client (or a bot) can't use up the API for everyone. Over the cap: 429 Too Many Requests, with a Retry-After header saying how long to wait.
Secrets: passwords, keys and connection strings. Locally in user-secrets (a file outside the project folder); in Azure in Key Vault, read with a managed identity, the app's own identity in Azure, so no password is needed to reach the vault.
XSS (cross-site scripting): an attacker's script gets into a page and runs in other users' browsers, where it can read their data. The defence is escaping output, so text is shown as text, never run.
CSRF (cross-site request forgery): a page on another site makes the user's browser send a request to your site, and the browser attaches the user's cookies by itself. The defence is SameSite cookies and an antiforgery token: a secret value the real page includes and another site can't know.
The senior answer · about 2 minutes
Layers, so one mistake isn't fatal
I think of it as layers in the pipeline. Forwarded headers first, so we know the real client IP behind , then the exception handler, then HTTPS redirection and HSTS, then CORS for our own front ends, then authentication, then rate limiting, then authorization, which is on by default through a fallback policy. Inside the endpoint, validation and a check that the record is the caller's. Each layer stops a different attack, and none of them has to be perfect on its own.
The holes I actually see
The biggest one in APIs is broken object level authorization: a valid user changes an id and reads someone else's data. Role checks don't stop it; only a per-record check does, and we test every by-id endpoint with another dealer's id. Next come endpoints that forgot [Authorize], which the fallback policy covers, and over-posting, which DTOs prevent.
Abuse and secrets
Public endpoints get rate limits per IP and signed-in ones per user. One detail people miss: the built-in limiter answers 503 unless you set 429, and a 503 tells clients and monitoring the server is down. At BMW FS that paged at 3 am and made a dealer system retry harder: the incident in Deep. Secrets live in , read through a , so there's no password anywhere in our config.
CSRF, XSS, and what I leave to the edge
Our APIs take bearer tokens in the Authorization header, which a browser never adds by itself, so cross-site request forgery doesn't apply; cookie-based APIs need SameSite cookies and antiforgery tokens. XSS is mainly an output-encoding problem in the front end, where Angular escapes by default. In front of the , Azure (Azure's public entry point) has a that blocks known attack patterns and floods before they reach our code. That costs money per rule set and request, so we use it on the public-facing APIs.
Every layer, on the real system
The same request on BMW FS's Azure setup: which part of the system does each job, from the dealer's browser to the database.
Dealer appin a browser
Front Door + WAFedge
Entra IDissues tokens
Dealer APIApp Service
Key Vaultsecrets
Azure SQLloans
App Insightslogs, alerts
On-callL2 + you
1/7Token. The dealer signs in with and the app gets a short-lived token.Edge., Azure's public entry point, ends HTTPS; its firewall blocks known attacks.API layers. CORS, token check, rate limit, authorization, validation: the main diagram.Secrets. At start-up the API read its secrets from with its .Data. The query goes to with parameters, over an encrypted connection.Answer. A DTO for loan 7; errors as ProblemDetails with an id, never details.Watch. Logs, with no tokens or personal data, go to ; 401, 403 or 429 spikes alert.
The story
Before
BMW FS's dealer API grew over three years, one endpoint at a time. Each developer added the security they remembered: [Authorize] on most controllers (an attribute, a label in square brackets, that makes an endpoint require sign-in; an endpoint is one address the API answers), a CORS rule copied from an old project (a browser rule about which other websites may call the API), a few checks inside actions. [AllowAnonymous] is the opposite label: open to everyone. Nothing was wrong on purpose; it just depended on everyone remembering everything. Every request passes through a pipeline, an ordered list of steps set up in Program.cs; the fix puts the security there.
Loans[Authorize]
Dealersno attribute
Quotesanonymous
1/4Before: every endpoint carries its own security, added by whoever wrote it.A new endpoint shipped without [Authorize]. It answered anyone, signed in or not.Fix: layers in the pipeline that every request passes, with sign-in required by default.Now a forgotten attribute means "signed in only"; public endpoints say [AllowAnonymous] on purpose.
Why it had to change
Security that depends on every developer remembering every rule fails the first time someone forgets. In BMW FS's case, a new dealers endpoint shipped without [Authorize], and for two weeks it answered anyone who knew the address.
How it works now
A senior answer to "how do you secure an API" is defence in depth: several layers, each stopping a different attack, set up once in Program.cs so every request passes them. HTTPS for the wire, CORS for browsers, authentication and authorization for who may call, rate limits for abuse, and inside the endpoint, validation and a check that the record is the caller's. Secrets stay out of the code. The main diagram walks one request through the layers, then three attacks.
What we gained
New endpoints are protected without anyone remembering to protect them, and each kind of attack has a known layer that stops it. The rest of the card covers secrets, CSRF and XSS, and the night BMW FS's new rate limiter answered with the wrong status code.
In the lending API
The pipeline: each line is one layer of the diagram above, in the same order. Set-up comes first in the file, so the walk jumps back to it. Layer 1, HTTPS redirection, is just above these four.
Only the key lines are shown; Deep walks the whole file.
Lending.sln · where this file sits
src/BmwFs.Dealer.Api/
Program.cs · line 75 · 1 of 4Layer 2: CORS headers for our dealer sites, before authentication so preflights (the browser's permission check, sent without a token) aren't refused.
The code, file by file
The BMW FS dealer API's Program.cs: every layer, in pipeline order. Tap Next to walk one request.
Lending.sln · where these 2 files sit
src/BmwFs.Dealer.Api/
Controllers/
Program.cs · line 5 · 1 of 42Reads the address ranges of from config: Azure's public entry point, a proxy (a server in front of our API that passes calls on). It is the only sender we trust to tell us the client IP.
What they ask next
The follow-ups interviews really ask. Answer out loud, then tap to check.
ReportedWhat are the common security practices for securing a REST API?
In layers. HTTPS with HSTS. Authentication with short-lived tokens, and authorization on by default through a fallback policy. A per-record check against BOLA. Server-side validation and DTOs. Parameterised SQL. Rate limits answering 429. CORS limited to our own front ends. Secrets in with a . Errors without internal details, and logs without tokens or personal data. Then monitoring and alerts on 401, 403 and 429 spikes.
ReportedWhat is an anti-forgery token, and does a JWT API need one?
It's the defence against CSRF. The server puts a secret value in the real page and in a cookie, and checks they match on every form post; another site can make the browser send the cookie but can't read the page, so it can't send the value. In MVC that's [ValidateAntiForgeryToken]. An API that takes a bearer token in the Authorization header doesn't need it: the browser never adds that header by itself. An API that uses cookies does, along with cookies.
ReportedHow do you protect a .NET application from XSS?
Encode output, so user text is shown, never run. Razor views encode by default; Angular escapes interpolated values and sanitizes [innerHTML] by default, so the danger is code that bypasses that. An API returns JSON with Content-Type: application/json and the X-Content-Type-Options: nosniff header, so a browser never treats it as a page. A Content-Security-Policy header on the front end limits which scripts can run at all. Input validation helps, but encoding is the real defence.
Answer, then open the next level. That is the order an interviewer digs in.
Reporteda candidate was asked Intervieweran interviewer asks it
ReportedWhat are the common security practices for securing a REST API?
In layers. HTTPS with HSTS. Authentication with short-lived tokens, and authorization on by default through a fallback policy. A per-record check against BOLA. Server-side validation and DTOs. Parameterised SQL. Rate limits answering 429. CORS limited to our own front ends. Secrets in with a . Errors without internal details, and logs without tokens or personal data. Then monitoring and alerts on 401, 403 and 429 spikes.
ReportedWhat is an anti-forgery token, and does a JWT API need one?
It's the defence against CSRF. The server puts a secret value in the real page and in a cookie, and checks they match on every form post; another site can make the browser send the cookie but can't read the page, so it can't send the value. In MVC that's [ValidateAntiForgeryToken]. An API that takes a bearer token in the Authorization header doesn't need it: the browser never adds that header by itself. An API that uses cookies does, along with cookies.
ReportedHow do you protect a .NET application from XSS?
Encode output, so user text is shown, never run. Razor views encode by default; Angular escapes interpolated values and sanitizes [innerHTML] by default, so the danger is code that bypasses that. An API returns JSON with Content-Type: application/json and the X-Content-Type-Options: nosniff header, so a browser never treats it as a page. A Content-Security-Policy header on the front end limits which scripts can run at all. Input validation helps, but encoding is the real defence.
: JavaScript can't read it, so an XSS bug can't steal it. : sent over HTTPS only. SameSite=Lax or : not sent on most requests started by other sites, which blocks most CSRF. A session or refresh-token cookie gets all three.
Where do you keep secrets like connection strings and API keys?
Never in appsettings files or the repository. Locally, dotnet user-secrets, a file outside the project folder. In Azure, , read at start-up with and , which signs in with the app's , so there's no password to protect. Better still, avoid the secret: and Storage accept the itself.
UseHttpsRedirection or HSTS: what's the difference?
Redirection answers each http:// request with a redirect (307 by default) to https://, but that first request already went out unencrypted. HSTS is a header that tells the browser to use HTTPS for this site for a set time (30 days by default in .NET), so later requests never go out as http:// at all. The template adds HSTS only outside Development, and it never applies to localhost.
Why does the rate limiter return 503, and what should it return?
503 is just the built-in default for . Set it to 429 Too Many Requests and add a Retry-After header in : 429 tells the client it's their own rate, and well-behaved clients wait. A 503 looks like an outage to monitoring and to retry policies.
What's on the OWASP API Security Top 10?
A list of the most common API holes. Interviewers mostly ask about the first few: broken object level authorization (changing an id to read another's record), broken authentication, broken object property level authorization (returning or accepting fields the caller shouldn't touch, which DTOs prevent), and unrestricted resource consumption (no rate limits or size limits). Name one, and say how your code prevents it.
Production bugs, and how to tell them
Each one: what you see, why, the fix in code, and the line to say.
The portal's API answered every website with the user's cookies
A cookie-based endpoint needed CORS with credentials. with made .NET throw at start-up, so a developer replaced it with SetIsOriginAllowed(_ => true), which allows every origin by echoing back whatever origin asks. Any website a signed-in user visited could now call the API with their cookies and read the answers.
Fix
List the exact origins from configuration with WithOrigins(...). A security test sends a request from an unknown origin and fails if the response allows it.
Before · Program.cs · line 3 · 1 of 1Allows every origin, by echoing it back. With credentials, that hands any website the user's session.
Say it
AllowAnyOrigin with credentials is refused by .NET for a reason. Echoing any origin back defeats that check. With credentials, list exact origins only.
A search box that could read the whole table
GET /api/dealers/search?name=x' OR '1'='1
→ 200 OK, 2,140 dealers
Cause
A dealer search used with the name pasted into the SQL string. The input x' OR '1'='1 closed the quote and added a condition that is always true, so the query returned every dealer: SQL injection.
Fix
Use LINQ, db.Dealers.Where(d => d.Name == name), or with an interpolated string. Both make EF Core send name as a SQL parameter. A test sends the same input and expects no rows.
Before · DealerSearch.cs · line 3 · 1 of 1The input becomes part of the SQL text.
Say it
Concatenating input into FromSqlRaw is SQL injection. LINQ, or FromSql with interpolation, sends the value as a parameter, so it can't change the query.
Production incident: The new rate limiter paged on-call at 3 am and broke a dealer's price sync
P3 · minorBMW FSonly under loadPayment calculator API (ASP.NET Core on App Service, behind Front Door)
Composed, not a real event at BMW FS. The mechanism is real ASP.NET Core, checked by a run on .NET 10: AddRateLimiter rejects with 503 Service Unavailable unless RejectionStatusCode is set, sends no Retry-After header unless OnRejected adds one, and keeps one counter per partition key. Times, counts, names, ticket and PR numbers are composed.
The story in 5 lines
At 03:10 ET on Sat 10 Oct the alert "failed requests above 5%" paged for the payment calculator API, which dealer websites use to show monthly payments.
Every failure was a 503 that took no time and logged no exception: the rate limiter added in Friday's release 6.4 to stop a price-scraping bot. Its rejections used the built-in default status, 503.
A 503 says "the server is down, try again". The alert counted them as outages, and Hudson BMW's dealer system, whose client retries any 5xx three times at once, so it made four times as many calls (1 plus 3 retries), and failed its nightly price sync.
That night a firewall rule blocked the bot and a config change raised the limit for the dealer system. The fix: 429 with a Retry-After header, an alert that counts only 5xx, separate limits for known dealer systems by API key, and the dealer system's vendor now honours Retry-After.
The rule since: a rate limiter answers 429 with Retry-After, and every new security control is load-tested with a real client's traffic before release.
Who's who (5 people)
You
.NET developer on the vendor team (L3). Owns the fix.
Tech lead
Vendor senior. Reviews every PR, joins when paged.
L2 on-call
Vendor support. First to be paged at night.
BMW FS dealer integrations manager
Owns the dealers' system connections to our APIs.
BMW FS IT change manager
Approves emergency and config changes.
Tap a step. The map lights the place where it happens.
Scraper bot3 IP addresses
Front Door + WAFedge
Hudson BMW DMSnightly price sync
Calculator APIrate limiter
L2 on-call + youpaged
App Insightsfailed-requests alert
Azure DevOpsPR, tests, release
Staging slotthe next build waits
1/8 Detect
03:10 ET: failed requests above 5%. The alert pages .
Sat 10 Oct, 03:10 ET, US Eastern (12:40 IST)L2 on-call, paged by the alert
What: the alert "failed requests above 5% for 10 minutes" fires for the payment calculator API. Dealer websites call this API anonymously to show a monthly payment next to each car.
How we saw it: is paged. Failed requests are at 31%, all status 503 Service Unavailable.
Why it looked bad: a 503 normally means the API or something it depends on is down, so the alert treats it as an outage.
The page L2 received
ALERT Sev2 · bmwfs-calculator-api
Failed requests > 5% for 10 min
Current: 31% (resultCode 503)
Fired 03:10 ET
Try it yourself · 10 minutes, on your Mac
dotnet new web -n RateLab. Add with a fixed-window policy of 3 calls a minute, app.UseRateLimiter(), and .RequireRateLimiting(...) on one endpoint.
Call it 4 times with curl: the 4th answer is 503, with no Retry-After.
Set o.RejectionStatusCode = 429 and add the code from the fix. Call it 4 times again: the 4th is 429 with Retry-After.
Partition by a header value instead of the IP, and send two different values: each gets its own 3 calls.
You should see
The limiter's default answer is 503, which means "server down". 429 with Retry-After tells a client it's their rate and how long to wait.
The senior move
"A rejection is part of the API's contract: 429 with Retry-After, and a separate limit for each known partner."
SourcesSourced: ASP.NET Core docs, "Rate limiting middleware" ( default, ,, partitioned limiters); HTTP semantics of 429 and Retry-After (RFC 6585, RFC 9110). Checked with a real run on .NET 10: the 4th call over a limit of 3 got 503 without the setting, 429 with it, and Retry-After from ; per-user and per-IP partitions kept separate counts. Composed: BMW FS's systems, the bot, Hudson BMW's , dates, counts, names, ticket and numbers and the .
Traps
Don't sayWe have CORS, so other sites can't call our API.SayCORS only limits pages in browsers. Scripts and Postman ignore it; authentication and authorization are the lock.
Don't sayKeep the secrets in appsettings.Production.json; it isn't checked in.Say with a . Files get copied, committed by mistake and read by anyone with server access.
Don't sayAdd the rate limiter and keep its defaults.SaySet RejectionStatusCode = 429 and send Retry-After; the default 503 looks like an outage.
Don't sayEvery endpoint has [Authorize], so we're covered.SayUntil someone forgets one. A fallback policy makes "signed in" the default, and a per-record check stops BOLA.
Traps
What should you say instead? Tap to check.
Don't sayWe have CORS, so other sites can't call our API.
Say instead
CORS only limits pages in browsers. Scripts and Postman ignore it; authentication and authorization are the lock.
Don't sayKeep the secrets in appsettings.Production.json; it isn't checked in.
Say instead
with a . Files get copied, committed by mistake and read by anyone with server access.
Don't sayAdd the rate limiter and keep its defaults.
Say instead
Set RejectionStatusCode = 429 and send Retry-After; the default 503 looks like an outage.
Don't sayEvery endpoint has [Authorize], so we're covered.
Say instead
Until someone forgets one. A fallback policy makes "signed in" the default, and a per-record check stops BOLA.
Live coding
Easy first, then harder. Set a timer and type it before you look.
Task 1
Question index: Musteasy · 10 minutes
Make every endpoint of an API require a signed-in user by default, except a health check that load balancers call without a token. Add HTTPS redirection and HSTS.
What status does the limiter return if you don't set one?
Where does the limiter know how long the caller must wait?
Show a solution
Lending.sln · where this file sits
Diagram · step 3 reads how long until the window resets and sends 429 with Retry-After.
Callerpriya
Limiterper caller
APIaction
call 100
permit
Program.cs · line 3 · 1 of 8Not the default 503.
What the interviewer checks
with , keyed on the user id or the IP.
RejectionStatusCode = 429.
reads from the lease and sets the Retry-After header.
after (so the user is known) and on the endpoints.
With AI tools
Interviewers now ask how AI tools change this. Generated code tends toward the permissive default: , an [AllowAnonymous] to make a test pass, a key pasted into appsettings. I review AI-written Program.cs files against the same layer list as this card, and keep secret scanning on the repository, because assistants happily repeat a key they saw in context. AI is also useful as a reviewer: asked "which endpoints in this controller lack an ownership check?", it finds BOLA candidates fast, which I then verify.