REST API security is the practice of protecting HTTP-based APIs through overlapping, independent controls, a strategy known as defense in depth, where each layer assumes the others might fail. Because REST APIs expose business logic, personal data, and payment flows to the open internet, a single weak endpoint can compromise an entire system. The essential layers are: strong authentication with short-lived JWT access tokens and rotating refresh tokens (or OAuth2/OIDC), precise object-level authorization to prevent BOLA/IDOR attacks, rigorous input validation, rate limiting and abuse protection, transport security with TLS and HSTS, strict CORS policies, secret management, idempotency and replay protection, minimized response payloads, and audit logging. Notably, the most dangerous vulnerabilities are rarely exotic; they are missing ownership checks, over-scoped tokens, and unvalidated input. This guide walks through each layer with production-ready examples in ASP.NET Core and Node.js, ending with a complete security checklist.
We will walk through the layers a mature API needs: strong authentication, precise authorization, rigorous input handling, abuse protection, transport security, secret management, replay protection, careful output shaping, and observability. Examples use ASP.NET Core and Node.js, but the reasoning transfers to any stack.
The most dangerous vulnerabilities are rarely exotic. They are missing ownership checks, over-scoped tokens, and unvalidated input on endpoints nobody thought were sensitive.
How Do You Prove Who Is Calling Your API?
Authentication answers a single question: who is making this request? The dominant pattern for stateless APIs is the JWT (JSON Web Token) access token, paired with a longer-lived refresh token. The access token is short-lived (5 to 15 minutes) and carries the identity and a minimal set of claims. The refresh token is long-lived but is exchangeable exactly once for a new pair. Keeping access tokens short limits the blast radius if one leaks.
Issuing an access token in ASP.NET Core
public string CreateAccessToken(User user)
{
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), // unique token id
new(ClaimTypes.Role, user.Role)
};
var key = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(_settings.SecretKey));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: _settings.Issuer,
audience: _settings.Audience,
claims: claims,
expires: DateTime.UtcNow.AddMinutes(15), // short-lived on purpose
signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
}Validation is where most JWT bugs hide. Always validate the issuer, audience, lifetime, and signing key, and explicitly pin the algorithm. Never accept the "none" algorithm, and never let the token header dictate which key or algorithm you trust, that is how algorithm-confusion attacks succeed.
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = settings.Issuer,
ValidAudience = settings.Audience,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(settings.SecretKey)),
ValidAlgorithms = new[] { SecurityAlgorithms.HmacSha256 },
ClockSkew = TimeSpan.FromSeconds(30) // default 5 min is too generous
};
});Refresh token rotation
A refresh token should be a high-entropy random value, stored server-side as a hash (never in plaintext), and rotated on every use. Rotation means: when a client redeems a refresh token, you invalidate it and issue a brand-new one. If a previously used token is ever presented again, that is a strong signal of theft, and you should revoke the entire token family for that user. This is the recommended pattern in the OAuth 2.0 security best current practice.
public async Task<TokenPair> RefreshAsync(string presentedToken)
{
var hash = Sha256(presentedToken);
var stored = await _db.RefreshTokens
.SingleOrDefaultAsync(t => t.TokenHash == hash);
if (stored is null || stored.ExpiresAt < DateTime.UtcNow)
throw new SecurityTokenException("Invalid refresh token");
// Reuse detection: an already-rotated token means possible theft.
if (stored.RevokedAt is not null)
{
await RevokeFamilyAsync(stored.FamilyId); // nuke the whole chain
throw new SecurityTokenException("Refresh token reuse detected");
}
stored.RevokedAt = DateTime.UtcNow; // one-time use
var next = IssueRefreshToken(stored.UserId, stored.FamilyId);
await _db.SaveChangesAsync();
return new TokenPair(CreateAccessToken(stored.User), next.PlainText);
}Where to store tokens on the client
Token storage trade-offs depend on the client type, and the wrong choice trades one vulnerability for another:
- Browser SPAs: prefer refresh tokens in an HttpOnly, Secure, SameSite cookie so JavaScript (and therefore XSS) cannot read them. Keep the access token in memory only.
- localStorage is convenient but readable by any injected script, so a single XSS becomes full token theft. Avoid it for anything long-lived.
- Mobile apps: use the platform secure storage (iOS Keychain, Android Keystore / EncryptedSharedPreferences), never plain AsyncStorage.
- Always pair cookie-based tokens with anti-CSRF defenses (SameSite plus a CSRF token or double-submit pattern) because cookies are sent automatically.
OAuth2 and OIDC in one paragraph
For third-party sign-in or delegated access, do not roll your own, use OAuth2 with OpenID Connect (OIDC). OAuth2 handles authorization (issuing access tokens with scopes), while OIDC layers identity on top via the ID token. For any public client (SPA, mobile, native), use the Authorization Code flow with PKCE and never the deprecated implicit flow. PKCE prevents an intercepted authorization code from being exchanged by an attacker. Validate the ID token signature against the provider JWKS, check the issuer, audience, and nonce, and only trust claims after verification.
How Do You Decide What Callers May Do?
Authentication tells you who; authorization tells you what they are allowed to touch. There are two levels, and APIs routinely get the second one wrong. Function-level authorization gates which endpoints a role can call. Object-level authorization gates which specific records this user may act on. The second is where Broken Object Level Authorization (BOLA), also called IDOR, lives, and it is consistently the number one API risk in the OWASP API Security Top 10.
Role, claim, and policy-based access
Role-based checks are the coarse layer. In ASP.NET Core, prefer policies over sprinkling role strings everywhere, because policies centralize the rule and can combine roles, claims, and custom requirements.
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("CanManageBilling", policy =>
policy.RequireAuthenticatedUser()
.RequireClaim("permissions", "billing:write"));
});
[Authorize(Policy = "CanManageBilling")]
[HttpPost("invoices")]
public Task<IActionResult> CreateInvoice(CreateInvoiceDto dto) => ...;Preventing BOLA / IDOR: check ownership server-side
The most common real-world breach is embarrassingly simple: an endpoint like GET /api/orders/1043 returns the order without confirming that the authenticated user actually owns order 1043. Change the id to 1044 and you read someone else's data. A role check does not help here, because both users may legitimately have the "customer" role. The fix is to always scope every object lookup by the caller identity, ideally in the query itself so it is impossible to forget.
// WRONG: trusts the id from the URL, ignores ownership
var order = await _db.Orders.FindAsync(id);
return Ok(order);
// RIGHT: the query can only ever return the caller's own orders
var userId = User.FindFirstValue(JwtRegisteredClaimNames.Sub);
var order = await _db.Orders
.SingleOrDefaultAsync(o => o.Id == id && o.CustomerId == userId);
if (order is null) return NotFound(); // do not reveal it existsTwo subtleties matter. First, return 404 rather than 403 for objects the user does not own, so you do not leak the existence of other records. Second, use non-sequential identifiers (UUIDs) where practical; they do not replace authorization, but they remove the trivial "increment the id" enumeration path.
How Do You Validate Input and Prevent Injection?
Every byte crossing your boundary is untrusted, including headers, query strings, path segments, and JSON bodies. Validate on a positive model: define exactly what is allowed (type, length, range, format) and reject everything else, rather than trying to blocklist bad input. Bind requests to explicit DTOs so attackers cannot mass-assign fields like isAdmin that you never intended to expose.
public class CreateUserRequest
{
[Required, EmailAddress, MaxLength(254)]
public string Email { get; init; } = default!;
[Required, StringLength(72, MinimumLength = 12)]
public string Password { get; init; } = default!;
[Range(18, 120)]
public int Age { get; init; }
// Note: no "Role" here. Privilege fields are never client-settable.
}For injection specifically: use parameterized queries or an ORM for all database access, never string concatenation. SQL injection remains devastating precisely because it is so easy to introduce with a single interpolated string. The same discipline applies to NoSQL (reject query operators in user-supplied objects), to OS commands (avoid shelling out with user input), and to output that ends up in HTML (encode it).
// Node.js: parameterized query, user input never becomes SQL
const { rows } = await pool.query(
'SELECT id, email FROM users WHERE email = $1 AND tenant_id = $2',
[email, tenantId] // values are bound, not interpolated
);
// Validate structured input at the edge with a schema (zod)
const CreateOrder = z.object({
productId: z.string().uuid(),
quantity: z.number().int().min(1).max(100),
});
const dto = CreateOrder.parse(req.body); // throws on anything unexpectedHow Do You Rate Limit and Prevent Abuse?
Without rate limiting, a single client can brute-force credentials, scrape data, or exhaust your resources. The token bucket algorithm is the workhorse: each client has a bucket that refills at a steady rate up to a cap; every request consumes a token, and when the bucket is empty, requests are rejected with 429 Too Many Requests. It permits short bursts while enforcing a sustained average, which matches real traffic better than a hard fixed window.
Key your limiter thoughtfully. Anonymous traffic is best limited per IP; authenticated traffic per user or per API key, so one abusive tenant cannot degrade everyone sharing an IP. Apply stricter limits to sensitive endpoints (login, password reset, token issuance) than to read-only ones.
// ASP.NET Core built-in rate limiting: token bucket per user/IP
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy("per-user", httpContext =>
RateLimitPartition.GetTokenBucketLimiter(
partitionKey: httpContext.User.Identity?.Name
?? httpContext.Connection.RemoteIpAddress?.ToString()
?? "anonymous",
factory: _ => new TokenBucketRateLimiterOptions
{
TokenLimit = 100, // bucket capacity (burst)
TokensPerPeriod = 100, // refill amount
ReplenishmentPeriod = TimeSpan.FromMinutes(1),
QueueLimit = 0
}));
});
app.UseRateLimiter();Rate limiting is one control among several abuse defenses. Layer them:
- Return standard 429 responses with a Retry-After header so well-behaved clients back off.
- In distributed deployments, back the limiter with Redis so counters are shared across instances instead of per-process.
- Add exponential backoff and account lockout on repeated failed logins to blunt credential stuffing.
- Cap request body size and JSON nesting depth to prevent memory-exhaustion and decompression-bomb attacks.
- Put a WAF or CDN-level rate limit in front of the origin so volumetric attacks never reach your application.
Transport Security: TLS, HSTS, Headers, and CORS
Everything above is meaningless if the connection can be read or tampered with. Serve the API exclusively over HTTPS with a modern TLS configuration (TLS 1.2 minimum, prefer 1.3), redirect any plaintext request, and enable HSTS so browsers refuse to downgrade to HTTP on future visits.
if (!app.Environment.IsDevelopment())
{
app.UseHsts(); // Strict-Transport-Security with a long max-age
}
app.UseHttpsRedirection();
// Security headers on every response
app.Use(async (ctx, next) =>
{
ctx.Response.Headers["X-Content-Type-Options"] = "nosniff";
ctx.Response.Headers["X-Frame-Options"] = "DENY";
ctx.Response.Headers["Referrer-Policy"] = "no-referrer";
ctx.Response.Headers["Content-Security-Policy"] = "default-src 'none'";
await next();
});CORS done correctly
CORS is a browser protection, not a server authorization mechanism, and it is routinely misconfigured. The cardinal sin is reflecting the request Origin while also allowing credentials, which effectively lets any site make authenticated requests on a user's behalf. Never combine a wildcard origin with credentials. Instead, keep an explicit allowlist of trusted origins and only enable credentials when you truly need cookies.
builder.Services.AddCors(options =>
{
options.AddPolicy("api", policy => policy
.WithOrigins("https://app.example.com") // explicit allowlist
.WithMethods("GET", "POST", "PUT", "DELETE")
.WithHeaders("Authorization", "Content-Type")
.AllowCredentials()); // never with a wildcard origin
});
app.UseCors("api");Secrets and Configuration Management
Signing keys, database passwords, and third-party API secrets must never live in source control or appear in a committed appsettings.json. A leaked repository with hard-coded credentials is one of the most common ways companies get breached. Load secrets from environment variables or, better, a dedicated secret manager (Azure Key Vault, AWS Secrets Manager, HashiCorp Vault) with access controlled by identity, not by a shared password.
Practical secret hygiene:
- Use per-environment secrets; a production key must never appear in staging or a developer laptop.
- Rotate keys on a schedule and support two valid signing keys at once so rotation causes zero downtime.
- Grant each service the least privilege it needs (a read-only DB user for read paths, for example).
- Add secret-scanning to CI (git hooks or GitHub secret scanning) to catch accidental commits before they merge.
- For local development use the .NET user-secrets tool or a .env file that is git-ignored, never a checked-in config.
How Do You Handle Idempotency and Replay Protection?
Sensitive endpoints, especially payments, must survive retries and resist replay. Network timeouts routinely cause clients to retry a POST that actually succeeded, which without protection means a customer is charged twice. The standard solution is an idempotency key: the client sends a unique key per logical operation, and the server guarantees the operation runs at most once, returning the original result for any repeat.
// Express: enforce at-most-once semantics with an idempotency key
app.post('/payments', async (req, res) => {
const key = req.header('Idempotency-Key');
if (!key) return res.status(400).json({ error: 'Idempotency-Key required' });
const existing = await store.get(key);
if (existing) return res.status(existing.status).json(existing.body); // replay
const result = await charge(req.body); // side effect happens once
await store.set(key, { status: 201, body: result }, { ttlHours: 24 });
return res.status(201).json(result);
});Replay protection goes further for signed requests and webhooks: include a timestamp and a nonce in the signed payload, reject anything older than a short window (say 5 minutes), and remember recently seen nonces so a captured-and-resent request is refused. For inbound webhooks, always verify the provider's signature before trusting the body.
Output Minimization and Avoiding Data Leakage
APIs leak sensitive data in two directions. In responses, returning your full database entity exposes fields like password hashes, internal flags, or another user's PII. Always project to an explicit response DTO that contains only what the client needs, rather than serializing the entity directly. This is Broken Object Property Level Authorization in OWASP terms, and it is easy to introduce by returning your ORM model straight from a controller.
The second leak is in errors. Verbose stack traces, SQL error text, or framework version banners hand attackers a map of your internals. Return a generic error body to clients, log the detail server-side, and correlate the two with a request id the client can quote to support.
// Client-facing error: no stack trace, no internals, just a correlation id
{
"error": "internal_error",
"message": "Something went wrong. Please try again.",
"requestId": "b3f1c8a2-9d47-4e10-a5e2-2c6f0d9a1e77"
}Audit Logging and Monitoring
You cannot respond to what you cannot see. Emit structured audit logs for security-relevant events: logins (success and failure), token issuance and refresh, authorization denials, privilege changes, and mutations on sensitive resources. Include who, what, when, and from where, but never log secrets, full tokens, passwords, or raw payment data. Ship logs to a central store, alert on anomalies (a spike in 401s or 429s, refresh-token reuse, a surge from one IP), and retain them long enough to investigate incidents.
_logger.LogWarning(
"Authorization denied {UserId} on {Resource} from {Ip} req {RequestId}",
userId, resourcePath, remoteIp, HttpContext.TraceIdentifier);
// Structured fields, no secrets, correlatable to the client-facing requestId.API Keys vs User Tokens
These solve different problems and should not be interchanged. User tokens (JWTs) represent a human, are short-lived, and carry that user's permissions. API keys represent a machine or integration, tend to be long-lived, and identify an application rather than a person. Treat keys accordingly: generate them with high entropy, store only a hash, scope each key to the minimum permissions and ideally to specific IP ranges, show the plaintext exactly once at creation, and make revocation and rotation first-class. Never use a raw API key where a user identity is required for object-level authorization, a key that can read any tenant is a BOLA waiting to happen.
Versioning and Deprecation Security
Old API versions are a security liability, not just a maintenance one. A v1 endpoint that skips a validation added in v2, or an unauthenticated legacy route left running, becomes the path of least resistance for attackers. Version explicitly (URL or header), apply security fixes to every supported version, and run a real deprecation process: announce timelines, monitor who still calls old versions, and actually shut them down. An endpoint you forgot exists is an endpoint you are not patching.
Dependency and Supply-Chain Hygiene
Most application code is code you did not write. A vulnerable transitive dependency, or a compromised package, can hand an attacker execution inside your API regardless of how clean your own code is. Keep dependencies current and treat updates as security work, not chores.
Baseline supply-chain controls:
- Run automated vulnerability scanning (npm audit, dotnet list package --vulnerable, Dependabot, Snyk) in CI and fail builds on high-severity findings.
- Pin versions with a committed lockfile so builds are reproducible and cannot silently pull a malicious update.
- Prefer well-maintained packages with a real release history; scrutinize new, low-download, or recently transferred packages.
- Verify integrity where possible (package signatures, checksums) and consider generating an SBOM for auditability.
- Minimize the dependency surface: every package you remove is an attack path you no longer have.
API Security Checklist
Use this as a pre-launch and periodic review gate. If any item is unchecked, treat it as a finding, not a nice-to-have.
Authentication and authorization
- Short-lived access tokens with issuer, audience, lifetime, and algorithm all validated (no "none", no header-chosen key).
- Refresh tokens are random, hashed at rest, rotated on use, with reuse detection that revokes the token family.
- OAuth2/OIDC public clients use Authorization Code + PKCE; ID tokens verified against JWKS with issuer, audience, and nonce.
- Every object access is scoped to the caller server-side (BOLA/IDOR), returning 404 for non-owned resources.
- Authorization enforced via centralized policies; privilege fields are never client-settable.
Input, output, and transport
- All input validated against a positive schema; requests bound to explicit DTOs to block mass assignment.
- Parameterized queries/ORM everywhere; no string-built SQL, NoSQL operators, or shell commands from user input.
- Responses projected to DTOs that expose only necessary fields; errors are generic with a correlation id.
- HTTPS-only with TLS 1.2+, HSTS enabled, and security headers (nosniff, frame-deny, CSP, referrer-policy) set.
- CORS uses an explicit origin allowlist and never combines a wildcard origin with credentials.
Abuse, secrets, and operations
- Rate limiting (token bucket) keyed per user/IP, stricter on auth endpoints, backed by Redis when distributed, returning 429 + Retry-After.
- Request size, JSON depth, and login attempts are capped; a WAF/CDN fronts the origin.
- Secrets live in a secret manager or env vars, are rotated, least-privileged, and scanned for in CI.
- Idempotency keys protect payment/sensitive writes; signed requests and webhooks use timestamp + nonce replay protection.
- API keys are hashed, scoped, revocable, and distinct from user tokens.
- Structured audit logs and alerting for auth events; no secrets logged.
- Old versions are patched and deprecated on a schedule; dependencies scanned, pinned, and kept current.
No single control on this list is sufficient alone, and that is the point. Authentication assumes tokens will occasionally leak, so it keeps them short and rotates them. Authorization assumes an id will be tampered with, so it checks ownership. Rate limiting assumes credentials will be guessed, so it slows the guessing. Layered together, each control covers the failure of another, and that overlap, defense in depth, is what turns a collection of endpoints into an API you can actually trust in production.
