Designing a Secure API: A Complete Practical Guide Beyond Just Authentication

Designing a Secure API: A Complete Practical Guide Beyond Just Authentication

Documenting security in OpenAPI, which I covered in the previous article, tells consumers how to authenticate. But real API security goes much deeper than that. In this article, I want to walk through everything I actually think about when I’m designing a secure API from the ground up — not just the auth layer, but the entire threat surface: input handling, transport, data exposure, abuse prevention, and the operational habits that keep an API secure over time, not just on launch day.

I’m going to be thorough here, because API security is one of those topics where “mostly secure” isn’t good enough. A single overlooked detail — one endpoint missing an authorization check, one field leaking more data than it should — is enough to cause a real incident.

Security Starts with Threat Modeling, Not Firewalls

Before I write any security-related code, I ask a simple question about every single endpoint: who should be able to do this, and what happens if someone who shouldn’t could?

I go through this exercise resource by resource:

  • Who can read this resource?
  • Who can create this resource?
  • Who can update this resource?
  • Who can delete this resource?
  • Does access depend on ownership (a user can only see their own orders) or is it role-based (only admins can see all orders)?

Writing these answers down explicitly, before implementation, catches a huge number of authorization bugs before they ever reach code. Most real-world API breaches I’ve read post-mortems on weren’t caused by exotic cryptography failures — they were caused by an endpoint that forgot to check “does this user actually own this resource?”

Authentication: Getting the Basics Genuinely Right

I covered the OpenAPI documentation side of authentication in the previous article, but here’s what I focus on architecturally:

  • Always use HTTPS, everywhere, with no exceptions. Not just for login endpoints — every single request, every environment, including internal service-to-service calls where possible.
  • Short-lived access tokens, paired with refresh tokens. I typically set access tokens to expire in 15 minutes to an hour, and use refresh tokens (stored more securely, often as httpOnly cookies for browser clients) to get new ones without forcing re-login.
  • Never put sensitive tokens in URLs. Query strings get logged by proxies, browsers, and analytics tools. Tokens belong in headers or secure cookies, never in the URL path or query string.
  • Rotate and revoke tokens properly. I make sure there’s always a real mechanism to invalidate a compromised token immediately, not just wait for natural expiry.

Authorization: The Part Everyone Underestimates

Authentication answers “who are you?” Authorization answers “what are you allowed to do?” I’ve seen far more security incidents caused by broken authorization than broken authentication.

Object-Level Authorization

This is the single most common API vulnerability I check for, and it’s consistently near the top of industry vulnerability lists for APIs specifically. The pattern looks like this:

GET /orders/12345

If my backend only checks “is this user logged in?” and not “does this user actually own order 12345?”, then any authenticated user can view (or worse, modify) any other user’s data just by changing the ID in the URL. I treat this check as mandatory on every single object-level endpoint, no exceptions, and I write automated tests specifically to catch regressions here.

Function-Level Authorization

The related issue: making sure regular users can’t call admin-only operations just because they know the URL exists.

DELETE /admin/users/999

If this endpoint only checks “is there a valid token?” without checking “does this token belong to an admin role?”, it’s broken — no matter how well-designed the authentication layer is.

Field-Level Authorization

Sometimes a user is allowed to see a resource, but not every field on it. A customer support agent might be allowed to view an order, but not the customer’s full payment card number. I design my serialization layer so it filters fields based on the requesting user’s role, never relying on the frontend to simply “not display” fields it received — because anyone can inspect raw API responses in browser dev tools.

Input Validation: Trust Nothing from the Client

Every piece of data that enters my API — request bodies, query parameters, path parameters, headers — gets validated against a strict schema, exactly the kind I described in the JSON Schema article earlier in this series.

  • Reject unknown fields using additionalProperties: false on request schemas, so unexpected data can’t sneak through.
  • Bound every string and array with maxLength and maxItems to prevent resource-exhaustion attacks.
  • Validate types strictly — don’t silently coerce a string "123" into a number if the schema says type: integer.
  • Sanitize anything that touches a database query, shell command, or file path, even after schema validation, since schema validation checks shape, not necessarily safety in every downstream context.

Preventing Injection Attacks

Even with schema validation in place, I remain deliberate about how data is used downstream:

  • Use parameterized queries or an ORM, never string-concatenated SQL, for any database interaction. This alone eliminates the vast majority of SQL injection risk.
  • Avoid executing raw shell commands built from user input. If I absolutely must, I use strict allow-lists rather than trying to blacklist dangerous characters.
  • Escape output appropriately for whatever context it ends up in — HTML escaping if it’s ever rendered in a web page, even though that’s technically a frontend concern, because APIs often feed content directly into templates.

Rate Limiting and Abuse Prevention

I design rate limiting as a first-class architectural concern, not something bolted on after a production incident.

  • Per-user and per-IP limits, since relying on just one or the other is easy to bypass (a malicious user can rotate IPs; an attacker behind a shared NAT can get innocent users rate-limited if you only key on IP).
  • Different limits for different operations. A GET /products endpoint can usually tolerate much higher traffic than POST /password-reset, which I intentionally throttle hard, since it’s a common target for abuse.
  • Exponential backoff guidance communicated through Retry-After headers, so well-behaved clients back off automatically instead of hammering a limit repeatedly.
  • CAPTCHA or additional verification on especially sensitive, high-abuse-risk endpoints like account creation or password reset, when rate limiting alone isn’t enough.

Protecting Sensitive Data

  • Encrypt data at rest, particularly anything classified as personal or financial information.
  • Encrypt data in transit, always, via TLS — and I keep TLS configurations current, disabling old, weak protocol versions and cipher suites.
  • Minimize what you store at all. The safest sensitive data is the data you never collected in the first place. If I don’t need a customer’s full card number (because a payment processor tokenizes it for me), I don’t store it. = Mask data in logs. I’ve seen logging middleware accidentally dump entire request bodies, including passwords and tokens, into plaintext logs. I explicitly configure logging to redact known sensitive field names.

Designing Safe Error Responses

This one surprises people. Overly detailed error messages are a real security risk. If a login endpoint says “user not found” for a bad username but “incorrect password” for a bad password, an attacker can enumerate valid usernames just by observing which message they get back. I standardize on generic messages for authentication failures:

{
  "code": "invalid_credentials",
  "message": "The email or password provided is incorrect."
}

Similarly, I never expose stack traces, internal file paths, database error messages, or framework version numbers in API error responses, since these details make an attacker’s reconnaissance dramatically easier. I log the detailed version internally, and return a generic, safe version externally, tied together by a traceId so support and engineering can still correlate the two.

CORS: A Commonly Misconfigured Layer

For browser-facing APIs, Cross-Origin Resource Sharing configuration matters a lot:

  • I never set Access-Control-Allow-Origin: * on an API that also accepts credentials (cookies or auth headers) — that combination is explicitly unsafe and most browsers will refuse it anyway, but I’ve seen people try workarounds that reintroduce the risk.
  • I maintain an explicit allow-list of trusted origins rather than reflecting whatever Origin header the request happens to send.

Dependency and Infrastructure Hygiene

Security design doesn’t stop at the API contract level:

  • Keep dependencies patched. A huge share of real breaches trace back to a known, unpatched vulnerability in a library, not a novel zero-day.
  • Principle of least privilege for infrastructure, meaning the API’s own database credentials, cloud IAM roles, and service accounts should have only the exact permissions they need, nothing broader “just in case.”
  • Secrets management. API keys, database passwords, and signing keys belong in a proper secrets manager, never hardcoded in source control or environment files committed to a repository.

Security Headers Worth Setting

Even for a pure JSON API (not serving HTML), a few HTTP headers are worth setting deliberately:

Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
Content-Security-Policy: default-src 'none'

These headers close off small but real attack surfaces, particularly relevant if any part of your API’s responses could ever be rendered or interpreted by a browser.

Building Security Into the Development Lifecycle

Designing a secure API isn’t a one-time task at project kickoff — it’s an ongoing discipline. I try to bake these habits into the actual workflow:

  • Security review as part of API design review, using the OpenAPI document itself as the artifact under review, checking every operation’s security requirements before implementation starts.
  • Automated schema validation in CI, so a request body that violates the defined schema is rejected before it ever reaches business logic.
  • Dependency scanning in CI, catching known-vulnerable packages automatically.
  • Regular access reviews, periodically auditing who and what actually has access to production systems and data, removing anything that’s no longer needed.
  • Logging and monitoring for abnormal patterns, like a sudden spike in 401 responses from a single IP, which often signals an attempted credential-stuffing attack in progress.

A Mental Checklist I Actually Use

When I review a new endpoint before it ships, I run through this list:

  • Does it require authentication (or is that intentionally, explicitly not required)?
  • Does it check object-level ownership, not just “is this user logged in”?
  • Does it check role/permission for admin-level actions?
  • Is every input field validated against a strict schema?
  • Are error messages free of internal details and safe against enumeration attacks?
  • Is sensitive data marked writeOnly and excluded from logs?
  • Is there a sane rate limit on this specific operation?
  • Is the response filtered to only the fields this particular caller should see?

Where This Fits Into the Bigger Picture

A secure API is built from dozens of small, deliberate decisions, not one big security feature. Documenting authentication and authorization clearly in OpenAPI (as I covered in the previous article) is the visible, contractual part of this. Everything in this article is the deeper architectural and operational discipline that makes that documented contract actually true in practice.

Security and performance often get discussed separately, but they’re deeply connected — a poorly performing API under load is also a more vulnerable one, since degraded systems fail in unpredictable, sometimes insecure ways. That’s exactly why the next article in this series moves to “Designing a Network-Efficient API.”

Key Takeaways

  • Threat-model every resource: who can read, write, and delete it, and under what conditions.
  • Object-level and function-level authorization failures are the most common real-world API vulnerabilities — check them explicitly, every time.
  • Validate all input strictly, and never trust client-side filtering to protect sensitive fields.
  • Design error messages to avoid leaking internal details or enabling enumeration attacks.
  • Bake rate limiting, secrets management, and dependency hygiene into your standard workflow, not just your incident response plan.

Security isn’t a feature you add at the end. It’s a lens you apply to every single design decision, from the first sketch of an endpoint to the last line of deployment configuration.

Total
1
Shares

Leave a Reply

Previous Post
Designing a Network-Efficient API: How to Reduce Latency, Bandwidth, and Round Trips

Designing a Network-Efficient API: How to Reduce Latency, Bandwidth, and Round Trips

Next Post
How to Describe API Security with OpenAPI (OAS): A Practical Guide to Authentication and Authorization in Your Spec

How to Describe API Security with OpenAPI (OAS): A Practical Guide to Authentication and Authorization in Your Spec

Related Posts