REST API Design: Resources, Versioning, and Auth That Hold Up (Part 2 of 3)

⏱ 9 min read

In the first post in this series, I covered REST's architectural constraints and the HTTP semantics - methods, status codes - that most APIs get subtly wrong. Constraints and semantics get you a technically correct API. They don't get you a good one.

A good API needs resources that are named sensibly, a versioning strategy that won't break your clients every quarter, an authentication pattern that matches your actual threat model, and errors that tell the caller something useful. That's what this post covers.


REST API Resource Design 🔗

Resources are the nouns of your API. Good resource design is the single biggest factor in how usable and evolvable your API will be.

URIs Should Identify Resources, Not Actions 🔗

# Good - noun-based, resource-centric
GET  /orders
GET  /orders/789
POST /orders
PUT  /orders/789
DELETE /orders/789

# Bad - verb-based, RPC-style
POST /getOrder
POST /createOrder
POST /deleteOrder

The HTTP method carries the action. The URI carries the identity of the resource the action applies to.

Singular vs. Plural 🔗

Use plurals for collections consistently:

/users          # collection
/users/42       # single resource
/users/42/posts # sub-collection

Mixing singular and plural is a maintenance and documentation headache.

Hierarchical Relationships 🔗

Nest resources to express ownership or containment, but limit nesting depth:

/users/42/orders         # orders belonging to user 42
/users/42/orders/789     # order 789 belonging to user 42

Deep nesting (more than two levels) is a sign that you may need to reconsider your resource model. /users/42/orders/789/items/1/reviews/5 is hard to work with. In my opinion, it's better to use query parameters to filter at the top-level collection:

GET /orders?userId=42

Resource Identifiers 🔗

Prefer opaque, stable identifiers. UUIDs or ULIDs are better than auto-increment integers for several reasons:

  • They don't expose record counts (a security and competitive concern)
  • They can be generated client-side without a round-trip
  • They're safe to use across environments without collision

Once you publish a URI, it is a contract. Change it only with proper versioning and redirect support.

Controller Resources 🔗

Some operations don't map cleanly onto CRUD. Use controller resources for these - named with a verb, invoked with POST:

POST /orders/789/cancel
POST /accounts/42/verify-email
POST /payments/refund

This is better than bending PUT or PATCH into something unnatural.


Versioning Strategies 🔗

APIs change. Resources get new fields, old fields get removed, behavior changes. Versioning is how you manage change without breaking existing clients.

URI Versioning 🔗

/v1/users
/v2/users

Pros: explicit, easy to route at the infrastructure level, easy to document and test separately, shows up clearly in logs.

Cons: technically violates REST's uniform interface (the same resource should have one canonical URI), pollutes the URI space, encourages "big bang" version increments rather than incremental evolution.

This is the most pragmatic approach and the most widely used.

Header Versioning 🔗

GET /users
Accept: application/vnd.myapi.v2+json

Pros: URIs remain stable, aligns with HTTP content negotiation, cleaner theoretically.

Cons: harder to test in a browser, not visible in logs without extra work, requires clients to set headers correctly, harder to cache with some intermediaries.

Query Parameter Versioning 🔗

GET /users?version=2

Pros: easy to test, explicit, no header management.

Cons: version in the query string is semantically awkward (it's not a filter parameter), can interfere with caching.

Additive Changes vs. Breaking Changes 🔗

The best versioning strategy is to not break clients in the first place. Additive changes are generally safe:

  • Adding new fields to a response
  • Adding new optional request parameters
  • Adding new endpoints
  • Adding new values to an enum (this can break strict deserializers - communicate it)

Breaking changes require a new version:

  • Removing or renaming fields
  • Changing field types
  • Changing URI structure
  • Changing status codes for existing operations
  • Removing endpoints

Be pragmatic about it, and don't take it to the extreme. An API version number is not a build number. Don't do that.

Versioning in Practice 🔗

# Requesting a specific version via header
curl -H "Accept: application/vnd.myapi.v2+json" https://api.example.com/users

# URI versioning
curl https://api.example.com/v2/users

Maintain multiple versions simultaneously during a deprecation window. Communicate deprecation timelines clearly via Deprecation and Sunset response headers:

Deprecation: true
Sunset: Sat, 31 Dec 2026 23:59:59 GMT
Link: <https://api.example.com/v2/users>; rel="successor-version"

Authentication Patterns 🔗

REST is stateless, which means credentials travel with every request. There are several established patterns.

API Keys 🔗

The simplest approach. A key is issued to a client and sent with every request, typically in a header:

curl -H "X-API-Key: abc123xyz" https://api.example.com/data

Or as a Bearer token variant:

curl -H "Authorization: ApiKey abc123xyz" https://api.example.com/data

Pros: simple to implement and use, easy to rotate. Cons: no expiration built in, broad access (all-or-nothing unless scoped), key must be kept secret.

HTTP Basic Authentication 🔗

Username and password encoded as Base64 in the Authorization header:

curl -u username:password https://api.example.com/data
# Which sends:
# Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=

Never use Basic auth over plain HTTP. Credentials are only obscured (Base64), not encrypted. Only acceptable over HTTPS.

Bearer Tokens (JWT / OAuth 2.0) 🔗

The dominant pattern for modern APIs. A token is obtained through an authentication flow and included as a Bearer token:

curl -H "Authorization: Bearer eyJhbGciOiJSUzI1NiJ9..." https://api.example.com/data

JWTs (JSON Web Tokens) are self-contained - they encode claims (user ID, roles, expiry) and are cryptographically signed. The server can validate them without a database lookup.

Key properties to verify when processing JWTs:

  • Signature validity (using the correct public key or shared secret)
  • exp claim (not expired)
  • iss claim (issued by the expected authority)
  • aud claim (intended for your service)

OAuth 2.0 defines flows for obtaining tokens. The authorization code flow is appropriate for delegated access on behalf of a user. Client credentials flow is appropriate for machine-to-machine APIs.

Mutual TLS (mTLS) 🔗

Both client and server present certificates. Strong authentication with no bearer token to steal, but operationally complex (certificate management, rotation). Common in zero-trust internal service meshes.

Choosing a Pattern 🔗

  • Public API, external developers: OAuth 2.0 with API keys for non-delegated access
  • Service-to-service: mTLS or client credentials flow
  • Internal tooling: API keys or JWT with short expiry
  • User-facing mobile/web: OAuth 2.0 authorization code flow with PKCE

Error Handling 🔗

Error responses deserve as much design attention as success responses. A client that receives an error needs to know: what went wrong, why it went wrong, and what (if anything) it can do about it.

Structure Your Error Responses 🔗

Ad-hoc error messages are hard to handle programmatically. Define a consistent error structure and use it everywhere:

{
  "type": "https://api.example.com/errors/validation-failed",
  "title": "Validation Failed",
  "status": 422,
  "detail": "The request payload contains invalid values.",
  "instance": "/orders/create",
  "errors": [
    {
      "field": "email",
      "code": "INVALID_FORMAT",
      "message": "Email address is not in a valid format."
    },
    {
      "field": "quantity",
      "code": "OUT_OF_RANGE",
      "message": "Quantity must be between 1 and 100."
    }
  ]
}

This follows RFC 7807 (Problem Details for HTTP APIs), a standard worth adopting. The type field is a URI that identifies the error type (it should resolve to documentation). The instance field identifies the specific occurrence.

Be Specific 🔗

A 400 with "bad request" is nearly useless. Tell the client:

  • Which field(s) failed validation
  • What constraint was violated
  • What the expected format or range is

Don't Leak Internals 🔗

Error responses must never expose:

  • Stack traces
  • Database error messages
  • Internal server paths
  • Framework-level exceptions
  • Secrets or keys

Log those internally. Return only what the client needs to understand and act on the error.

Distinguish Permanent from Transient Errors 🔗

  • 4xx errors are the client's fault. Retrying the same request unchanged is pointless.
  • 5xx errors are the server's fault. The client may retry, ideally with exponential backoff and jitter.

Make this distinction explicit in your API documentation and, where possible, in the response itself.

Correlation IDs 🔗

Return a correlation ID in every error response (and ideally every response):

X-Correlation-Id: 7f3a2b1c-4d5e-6f7g-8h9i-0j1k2l3m4n5o

This allows support teams to trace a client-reported error back to server-side logs without guessing.


Closing 🔗

Resource design, versioning, and auth get your API to a place where it's usable and safe to evolve. What's still missing is what happens once real traffic hits it: pagination, discoverability, rate limits, and idempotency.

That's what the last post in this series covers.

PS: Let me know if I missed anything - ping me on Twitter/X or LinkedIn, and follow along there if you'd like more of this. Let's chat.

Enjoyed this?

I write about .NET, messaging, and distributed systems most weeks - the parts that don't make it into a LinkedIn post.