This article is published in English.
Securing Express APIs: Auth, Validation, Rate Limits and Monitoring
A practical Express walkthrough of API security: Passport and JWT auth, authorization models, AES-GCM encryption, validation, rate limiting and logging.
Every API you expose is a door into your system, and attackers probe those doors far more systematically than most teams test them. Security that is bolted on after launch tends to leave gaps: a route without a guard, a query built from raw input, a login endpoint that happily accepts a million guesses. This guide walks through the standards worth knowing, the threats that show up most often, and six concrete layers of defense implemented in Node.js with Express, so you can audit an existing API or build a new one with protection in place from day one.
Treat what follows as a checklist you return to throughout the development life cycle: before a release, after a patch, and whenever a dependency or a route changes. Running these checks routinely is how vulnerabilities get caught while they are still small.
Why APIs deserve dedicated security attention
Modern products are increasingly assembled from APIs. Instead of building every capability in-house, teams integrate payment, identity, messaging and data services through well-defined interfaces, and many companies now ship API-first products. One market study values the API economy at roughly $20 billion for 2026 (report summary); whatever the exact figure, the dependency is real and growing.
That reliance cuts both ways. An API gives legitimate clients ready, reusable functionality, and it gives attackers a documented, machine-friendly entry point. Industry surveys consistently link a large share of breaches to APIs; Traceable's 2023 report attributes 74% of data breaches to them.
APIs frequently sit directly in front of the most sensitive data a company holds: identity platforms with personal details, financial records, internal workflows. Unauthorized access can mean corrupted data, abused services, financial loss, broken customer trust and regulatory penalties under data protection law. Because the stakes are that high, security belongs in service level agreements alongside uptime, and it is the responsibility of every product team, not just a dedicated security group. The sensible place to start is with the standards the industry has already agreed on.
Standards and frameworks worth knowing
API security standards are formal specifications, protocols and guidelines applied across the software development life cycle so that protection is consistent rather than improvised. The ones you will meet most often:
- OWASP API Security Top 10: a ranked list of the most critical API risks, maintained by the Open Web Application Security Project. It covers issues such as broken object-level authorization, server-side request forgery and unrestricted resource consumption, and it is the best single starting point for a threat review.
- OAuth 2.0 and 2.1: a delegated authorization framework. A client obtains an access token with defined scopes and uses it to act on a user's behalf without ever handling that user's password; refresh tokens let the client obtain new access tokens without prompting the user again.
- OpenID Connect (OIDC): an identity layer on top of OAuth. It standardizes an ID token and how clients validate it, which is what makes single sign-on and profile retrieval from identity providers interoperable.
- JSON Web Tokens (JWTs): a compact, signed token format. Unlike an opaque session cookie, a JWT carries its claims (user ID, role, expiry) inside the token itself, and the server verifies the signature instead of looking up a session. Keep in mind that a standard signed JWT is encoded, not encrypted, so anyone holding it can read its payload.
- Transport Layer Security (TLS): the cryptographic protocol behind HTTPS. It provides encryption in transit, authentication of the communicating parties and integrity checks that detect tampering.
- Zero Trust: an architectural stance that treats every user and service as untrusted until verified. It rests on explicit verification, least privilege and the assumption that a breach has already happened somewhere.
- NIST Cybersecurity Framework: guidance that applies well to cloud and microservice architectures, with emphasis on authentication, authorization and data protection.
- Financial-grade API profiles (FAPI): hardened OAuth and OIDC profiles for high-stakes domains such as banking, fintech and regulated data. They require strong client authentication, tighter request and response handling, and sender-constrained tokens that are useless if stolen, which both prevents fraud and improves interoperability.
These give you a solid foundation, though they are not an exhaustive list.
The threats you are defending against
The vulnerabilities that appear most often in real APIs are:
- Broken authentication: weak or missing identity checks and poor session handling let an attacker steal cookies or tokens and reuse them to reach your services.
- Broken object-level authorization (BOLA): the API checks that a user is logged in but not that they are allowed to touch a specific record, so changing an ID in the URL exposes someone else's data or internal workflows.
- SQL injection: attacker-controlled input is concatenated into a query and executed by the database. Nearly every database client offers a parameter mechanism that passes values safely.
- Command injection: untrusted input from a request reaches a system shell or command processor, letting the attacker run arbitrary commands with the privileges of your server process.
- Cross-site scripting (XSS): injected script runs in a victim's browser in the context of your application, so the attacker can act as that user and read data the same-origin policy would normally protect.
- Security misconfiguration: leaked API keys, exposed environment variables, permissive defaults. Compromised credentials can be used to call your API or to run up bills on paid third-party services.
- Excessive or sensitive data exposure: a handler returns the whole database object instead of the fields the client needs. Even if the UI never displays it, the data sits in caches, local storage and the browser's network tab.
- Denial of service (including distributed attacks): a flood of requests exhausts server resources or takes the API offline entirely.
- Third-party dependencies: your API is also a client of other APIs and packages. Each one extends your attack surface, and a breach or outage there becomes your problem.
Each of these has a corresponding programming practice. The rest of this guide covers them in six layers, using JavaScript and Express throughout.
1. Authentication: proving who the caller is
Every protected API should make the client prove its identity before doing anything meaningful, whether through a username and password, an API key or a signed token. Authentication methods generally fall into five families: username and password, multi-factor authentication, token-based authentication, certificate-based authentication and biometrics.
A common source of confusion is what a JWT actually replaces. Traditional server sessions stored state on the server and relied on browser cookies to carry a session ID. A JWT removes the need for that server-side lookup on each request, but it does not verify credentials by itself: something still has to check the password once before the token is issued. That is why a hybrid flow works well. The user signs in with email and password, the server issues a JWT on success, and every later request presents only the token. Credential checking and per-request authentication become separate concerns, and the password no longer travels with every call.
In Express, the Passport library supports both halves through pluggable strategies. The setup takes three steps.
Step 1: register a local strategy and a JWT strategy
The local strategy runs once, at login, and is responsible for looking up the user by email and comparing the submitted password with the stored hash. The JWT strategy runs on every protected request: it extracts the token from the Authorization: Bearer header, verifies the signature against JWT_SECRET, and resolves the user referenced in the payload. Exporting a preconfigured authenticateJWT middleware with session: false makes the stateless intent explicit.
const passport = require("passport");
const LocalStrategy = require("passport-local").Strategy;
const { Strategy: JwtStrategy, ExtractJwt } = require("passport-jwt");
// Local Strategy: Verify username and password during login.
passport.use(
new LocalStrategy(
{ usernameField: "email", passwordField: "password" },
async (email, password, done) => {
// Find the user and compare the hashed password.
// If valid, return the user.
}
)
);
// JWT Strategy: Verify the token on protected requests.
passport.use(
new JwtStrategy(
{
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: process.env.JWT_SECRET,
},
async (payload, done) => {
// Find the user referenced in the token.
}
)
);
// Middleware
const authenticateJWT = passport.authenticate("jwt", { session: false, });
module.exports = { passport, authenticateJWT, };
The verification callbacks are left as comments here, and that is where the real security work lives. Use a slow, salted hashing algorithm such as bcrypt or Argon2 for the password comparison, and return the same generic failure whether the email is unknown or the password is wrong, so the endpoint cannot be used to discover which accounts exist.
Step 2: authenticate at login and sign a token
The login handler invokes the local strategy through a custom callback. An error goes to Express's error handling, a missing user produces a 401, and a successful match yields a token signed with the user's ID and role and an expiry taken from JWT_EXPIRES_IN, falling back to two hours.
const jwt = require("jsonwebtoken");
const passport = require("passport");
const login = (req, res, next) => {
passport.authenticate("local", { session: false }, (err, user, info) => {
if (err) return next(err);
if (!user) return res.status(401).json({ message: info.message, });
// Issue a signed JWT after successful authentication.
const token = jwt.sign(
{ id: user.id, role: user.role,},
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRES_IN || "2h",}
);
return res.status(200).json({ message: "Login successful.", token, user,});
})(req, res, next);
};
Two details deserve attention. The short expiry limits how long a stolen token remains useful; if sessions need to last longer, pair short access tokens with a refresh flow such as the one described in our refresh token strategy for Node.js authentication. And the response returns user as-is. If that object is a raw database row, it may include the password hash and internal fields, which is exactly the excessive data exposure described earlier. Return an explicit subset such as ID, email and role instead.
Step 3: guard protected routes
With the middleware exported, protecting a route means placing authenticateJWT before the controller in the route definition. Requests without a valid token are rejected before any business logic runs.
const { Router } = require("express");
const authRouter = Router();
// Get auth middleware and sample prorected controller
const authController = require("../controllers/auth.controller");
const { authenticateJWT } = require("../middleware/authentication");
// Use JWT as a guard to protect certain routes
authRouter.get("/me", authenticateJWT, authController.me);
authRouter.patch("/password", authenticateJWT, authController.updatePassword);
If you prefer lean routers, the same guard can instead be included in the controller's own middleware chain. Either way, make protection the default for a router and exempt public routes deliberately, rather than remembering to add the guard one route at a time.
2. Authorization: deciding what the caller may do
Authentication answers "who are you?"; authorization answers "what are you allowed to do?". It usually runs right after authentication and evaluates every identity against access rules before granting or denying a request. Without it, any logged-in user can read sensitive data or trigger privileged actions, which is precisely how BOLA vulnerabilities arise.
Three models cover most needs:
- Role-based access control (RBAC) assigns permissions to roles and roles to users. A blogging API might have admin, editor and viewer roles. It suits stable job functions and groups, which is why it is common in enterprise applications.
- Attribute-based access control (ABAC) evaluates attributes of the user, the resource and the request environment (department, resource sensitivity, time of day, network) against policies. It fits APIs whose decisions are highly contextual or change frequently.
- Relationship-based access control (ReBAC) grants access based on the relationship between a user and a specific resource, such as ownership or group membership, typically checked by traversing a graph of relationships. It is a natural fit for collaborative products like document sharing or social platforms.
Building authorization yourself is an excellent way to understand its subtleties, but production systems often delegate token issuance and validation to an identity provider. When an API is registered with a provider such as Microsoft Entra ID and configured to accept bearer tokens, sensitive endpoints validate each token's scopes and roles before executing. An invalid token or missing permission results in a 401 Unauthorized. In Express, the protected route looks like this:
app.get(
"/api/orders",
passport.authenticate("oauth-bearer", { session: false }),
(req, res) => {
res.json({ message: "Protected resource." });
}
);
Keep in mind that a validated token only establishes coarse permissions. Object-level checks, such as "does this order belong to this user?", still have to happen in your handler or data layer, because no identity provider knows who owns row 4812 in your database.
For the protocols themselves, rely on industry standards such as OAuth 2.0, OpenID Connect and SAML. You can implement the flows yourself or delegate them to identity providers such as Ping Identity, Okta, Microsoft Entra ID, AWS or IBM Security Verify.
3. Encryption: protecting data in transit and at rest
Encryption turns readable data into ciphertext that is useless without the right key. TLS protects data while it travels; encryption at rest protects it where it is stored, including the database. Sensitive systems generally use both, because without encryption, data such as financial credentials can be intercepted or lifted from a compromised store.
The main approaches trade speed against key management:
- Symmetric encryption uses one shared key. It is very fast and handles large volumes well, making it the right choice for data at rest and payload encryption, but both parties must hold the same secret securely.
- Asymmetric encryption uses a public and private key pair, so no shared secret needs to be exchanged. It is considerably slower and only practical for small pieces of data.
- Hybrid encryption combines the two: asymmetric cryptography protects a symmetric key, and the symmetric key protects the bulk data. You get the key-exchange benefits of the first and the speed of the second.
For symmetric encryption, Node's built-in crypto module supports AES-256-GCM. The handler below serializes the request body, generates a fresh 12-byte initialization vector, encrypts the data, and returns the IV, the GCM authentication tag and the ciphertext as hex strings.
const crypto = require("crypto");
const algorithm = "aes-256-gcm";
const key = Buffer.from(process.env.ENCRYPTION_KEY, "hex");
app.post("/api/orders", (req, res) => {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv(algorithm, key, iv);
const encrypted = Buffer.concat([
cipher.update(JSON.stringify(req.body), "utf8"),
cipher.final(),
]);
const payload = {
iv: iv.toString("hex"),
tag: cipher.getAuthTag().toString("hex"),
data: encrypted.toString("hex"),
};
// Store or transmit the encrypted payload
res.json(payload);
});
Several things make this correct. The key must be exactly 32 bytes (64 hex characters in ENCRYPTION_KEY) and should come from a secrets manager rather than source code. The IV must be unique for every encryption under the same key; reusing an IV with GCM is catastrophic, which is why it is generated per request. The authentication tag is what lets the decrypting side detect tampering, so it must be stored alongside the ciphertext and checked on decryption. In a real service you would persist this payload or forward it rather than send it back to the caller as the demo does.
Asymmetric encryption is also available in the same module. Data encrypted with a public key can only be decrypted with the matching private key:
const crypto = require("crypto");
const encrypted = crypto.publicEncrypt(
publicKey,
Buffer.from("Sensitive API data")
);
Because RSA can only encrypt a payload smaller than its key size, publicEncrypt is suited to short values such as a secret field or a symmetric key, not whole documents. That limitation is what the hybrid workflow addresses: generate a temporary AES key, encrypt the payload with it, encrypt the AES key with the recipient's RSA public key, and send both. The recipient uses its private key to recover the AES key, then decrypts the payload. Most APIs will never need this in application code, since TLS already performs a similar exchange, but it is worth understanding for end-to-end encryption scenarios.
4. Input validation and sanitization
Once your API accepts client data, you cannot predict what will arrive. Malformed bodies, SQL fragments and script payloads all look like ordinary strings until something interprets them. Two complementary techniques address this. Validation rejects input that breaks your structural and semantic rules. Sanitization transforms accepted input into a safe, normalized form before it reaches your handlers.
Enforce the content type first
The cheapest check is the format of the request itself. This small middleware factory uses req.is() to confirm the Content-Type and responds with 415 Unsupported Media Type otherwise. It can be mounted globally, per router or per endpoint.
const requireContentType = (type) => (req, res, next) => {
if (!req.is(type)) {
return res.status(415).json({ error: "Unsupported Media Type", });
}
next();
};
app.post("/api/users", requireContentType("application/json"),
(req, res) => {
res.json({ message: "User created." });
}
);
Validate the shape and meaning of the body
With the format enforced, the next layer checks that the request is well formed. Using express-validator, keep the rules in a dedicated validator module. This one requires a syntactically valid email, runs an asynchronous custom check that rejects addresses already in the database, and enforces a minimum password length of eight characters.
const { body } = require("express-validator");
const { getUserEmail } = require("../db/queries");
const validateRegistration = [
body("email")
.isEmail()
.withMessage("Invalid email format")
.custom(async (value) => {
if (await getUserEmail(value)) {
throw new Error("Email is already in use");
}
return true;
}),
body("password")
.isLength({ min: 8 })
.withMessage("Password must be at least 8 characters long"),
];
module.exports = { validateRegistration }
The validator array is then placed in the route's middleware chain. Inside the handler, validationResult(req) collects any failures, and the route returns a 400 with the full list rather than continuing.
const { validationResult } = require("express-validator");
const { validateRegistration } = require("../validators/userValidator");
app.post("/api/register", validateRegistration, (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
res.json({ message: "Registration successful." });
});
Sanitize after validation
Because Express processes middleware in order, a sanitization chain can sit right after validation. Here the first name is trimmed and HTML-escaped, and the email is normalized.
const sanitizeRegistration = [
body("firstName").trim().escape(),
body("email").normalizeEmail(),
];
app.post(
"/api/register",
validateRegistration,
sanitizeRegistration,
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
res.json({ message: "Registration successful." });
}
);
Order matters in a subtle way here. The uniqueness check in the validator runs before normalizeEmail(), so two spellings of the same address could slip past it and create duplicate accounts. Normalizing before the lookup, or enforcing uniqueness on the normalized value at the database level, closes that gap. Also be deliberate about escape(): HTML-encoding on input protects templates that render the value, but it changes the stored data, and many teams prefer to store raw values and encode on output instead. If you are weighing validation libraries, our comparison of Zod and express-validator covers the trade-offs.
Use parameterized queries for the database
Never build SQL by concatenating user input. Database clients such as pg and ORMs such as Prisma support parameterized queries, which send the query text and the values separately so the database always treats input as data, never as executable SQL.
With pg, create a connection pool once and export it for your data modules:
const { Pool } = require("pg");
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
module.exports = pool;
Queries then use numbered placeholders ($1, $2) with the values supplied as a separate array. Even if email contains a quote followed by a DROP TABLE, it is stored as a literal string.
app.post("/api/users", async (req, res) => {
const { email, name } = req.body;
await pool.query(
"INSERT INTO users (email, name) VALUES ($1, $2)",
[email, name]
);
res.status(201).json({ message: "User created." });
});
Together these pieces give you a layered pipeline. Express-validator lets you isolate rules as reusable units that run as middleware and report each failure, while parameterization means that even input that slips through validation cannot rewrite your queries.
5. Rate limiting and throttling
Rate limiting caps how many requests a client can make within a time window. It blunts brute-force and denial-of-service attempts, and it keeps one heavy consumer from starving everyone else.
Limits can be applied along different dimensions:
- Per client: requests are counted per API key or IP address. When a client hits the ceiling, it waits for the window to reset or arranges a higher quota, often on a paid tier.
- By geography or time: limits vary by region or time window, for example allowing more traffic from regions where your customers operate and tightening limits where suspicious traffic originates.
- By server capacity: some parts of an API are routed to dedicated infrastructure with its own limits, such as a small pool of background workers for expensive jobs.
Many algorithms exist (fixed window, sliding window, token bucket), and you do not need to implement them yourself to get started. The express-rate-limit middleware counts requests per IP address by default. The example below sets a general budget of 100 requests per 15 minutes for everything under /api and a much stricter five attempts per five minutes for login, with a custom message for rejected requests.
const rateLimit = require("express-rate-limit");
// Apply to all API routes
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100,
});
// Apply stricter limits to authentication endpoints
const loginLimiter = rateLimit({
windowMs: 5 * 60 * 1000, // 5 minutes
max: 5,
message: "Too many login attempts. Please try again later.",
});
app.use("/api", apiLimiter);
app.post("/api/login", loginLimiter, (req, res) => {
res.json({ message: "Login successful." });
});
Public APIs usually issue each consumer a key, and limiting by that key is fairer than limiting by IP, since many users can share one address behind a corporate proxy. A custom keyGenerator reads the X-API-Key header and uses it as the counter's identity.
const rateLimit = require("express-rate-limit");
const apiKeyLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 1000,
keyGenerator: (req) => req.get("X-API-Key"),
});
app.use("/api", apiKeyLimiter);
As written, every request that omits the header produces the same undefined key and shares a single bucket. In practice, reject keyless requests earlier in the chain or fall back to the IP address.
Throttling expensive endpoints
Throttling shapes how quickly requests are accepted so that sudden spikes do not overwhelm the service. The next configuration uses the same middleware with very short windows: at most ten requests per second across the API, and only one search request every two seconds per client, because search is the most resource-intensive endpoint.
const rateLimit = require("express-rate-limit");
// Throttle all API requests
const apiThrottle = rateLimit({
windowMs: 1000, // 1 second
max: 10, // Allow up to 10 requests per second
});
// Apply a stricter throttle to resource-intensive endpoints
const searchThrottle = rateLimit({
windowMs: 2000, // 2 seconds
max: 1, // Allow 1 request every 2 seconds
message: "Please wait before sending another search request.",
});
app.use("/api", apiThrottle);
app.get("/api/search", searchThrottle, (req, res) => {
res.json({ results: [] });
});
Strictly speaking, this is still rate limiting with small windows: excess requests are rejected with a 429, not delayed. If you want true throttling that slows clients down before refusing them, a companion package such as express-slow-down adds progressive delays. Also note that the default in-memory store counts per process, so behind a load balancer with several instances you need a shared store such as Redis for the limits to hold. Recent express-rate-limit releases also name the max option limit; check the documentation for the version you install. For a lightweight TypeScript alternative, see our write-up on a minimal rate limiter for Express.
6. Logging, monitoring and incident detection
You cannot respond to an attack you never see. Logging records requests and responses with their metadata, context, timings and error codes, so you can troubleshoot, audit and understand real usage. Monitoring watches activity in real time, tracks indicators such as latency, error rates and throughput, and surfaces anomalies that might signal abuse, missed service-level targets or a vulnerability being exploited.
The main approaches, with their trade-offs:
- Request logging with middleware such as Morgan captures every incoming HTTP request cheaply, but says nothing about system health.
- Application logging with Winston, Pino or Bunyan records business and security events such as logins, database operations and errors. Larger systems need structured output and centralized storage to make it useful.
- Metrics with Prometheus and Grafana track request rates, latency, CPU, memory and error rates. They show aggregate trends but not what happened in a single request.
- Centralized log management with the ELK Stack, OpenSearch, Splunk or CloudWatch aggregates logs across services, at the price of extra infrastructure and operational work.
- Application performance monitoring with Datadog, New Relic or Dynatrace combines logs, metrics, traces and alerting in one platform, at higher licensing cost and platform complexity.
Any of these plugs into Express. Four common building blocks follow.
Request logging with Morgan
Registering Morgan with the combined format writes an Apache-style line for every request, including method, path, status, response size and user agent.
const express = require("express");
const morgan = require("morgan");
const app = express();
// Log every incoming request
app.use(morgan("combined"));
app.get("/api/users", (req, res) => {
res.json({ message: "Users retrieved successfully." });
});
Structured application logging with Winston
Winston records events as structured objects. Logging the user and order IDs when an order is created produces an audit trail you can search later.
const winston = require("winston");
const logger = winston.createLogger({
transports: [
new winston.transports.Console(),
],
});
app.post("/api/orders", (req, res) => {
logger.info("Order created", {
userId: req.user.id,
orderId: req.body.id,
});
res.status(201).json({ message: "Order created." });
});
Be careful with what goes into logs. User IDs are fine; passwords, tokens, full card numbers and entire request bodies are not, and logs are a common place for sensitive data to leak.
Centralized error logging
An Express error-handling middleware, recognizable by its four arguments, catches errors from any route. This one logs the message with the path and method and returns a generic 500 so that stack traces and internal details never reach the client.
app.use((err, req, res, next) => {
/* Logger is built as an independent module or class */
logger.error(err.message, {
path: req.originalUrl,
method: req.method,
});
res.status(500).json({
error: "Internal Server Error",
});
});
Exposing metrics for Prometheus
The prom-client library collects default Node.js process metrics and exposes them on a /metrics endpoint for Prometheus to scrape.
const client = require("prom-client");
client.collectDefaultMetrics();
app.get("/metrics", async (req, res) => {
res.set("Content-Type", client.register.contentType);
res.end(await client.register.metrics());
});
That endpoint reveals internal details about your service, so restrict it to your monitoring network or put it behind authentication rather than leaving it public.
Opinionated frameworks help here. NestJS ships with a built-in logger and a structure that makes it easy to hook in logging and metrics for every endpoint, whereas with an unopinionated framework like Express you have to add logging deliberately, typically as middleware that runs before the response is sent. Incident detection then builds on these logs: route alerts on suspicious patterns, such as spikes in 401 responses or rate-limit rejections, to the notification channel your team actually watches.
Security as a continuous process
No single article covers everything, but before any release you can confirm that your API meets this baseline:
- Every endpoint is served only over HTTPS.
- OAuth or an equivalent token flow is in place.
- Issued JWTs carry an expiry.
- Limits protect all routes, with tighter ones on login.
- Each input is validated before use.
- Queries have been tested against injection attempts.
- Access rules have been tested, including object-level checks.
- Keys and secrets live outside the codebase.
- Security-relevant events are logged.
- Dashboards and alerts are configured.
- Responses include security headers.
- Clients never see stack traces or internal error details.
- Dependencies are current and audited.
Express was used here for the examples, but the same ideas carry over to other backend frameworks, most of which either integrate these tools directly or provide native equivalents. NestJS, for instance, handles request validation through data transfer objects. Your framework's documentation will show the idiomatic version of each layer.
The same principles also form the baseline for cloud-native deployments on platforms such as Azure, Google Cloud and AWS, which add their own gateways, identity services and managed rate limiting on top. Cloud-specific practices deserve their own treatment, but the layers above already take an API most of the way to passing a security review.
Key takeaways
- Separate credential checking from per-request authentication: verify the password once, then rely on short-lived signed tokens.
- Authentication is not authorization. Validate scopes and roles, and still check ownership of every object a request touches.
- Use AES-GCM with a unique IV per operation for data at rest; reserve asymmetric encryption for small values and key exchange.
- Layer content-type checks, validation, sanitization and parameterized queries, and think about the order in which they run.
- Rate-limit everything, apply stricter limits to login and expensive endpoints, and use a shared store once you run more than one instance.
- Log security-relevant events without logging secrets, and turn those logs into alerts someone will act on.