APIs

Authentication Mistakes in a First-Party API

An API that only its own developers call has a particular failure mode, and it usually shows up in the gap between the browser and the server. The first-party client is trusted by definition, so the temptation is to treat the network edge as a formality. That reasoning holds right up until someone opens the network tab, copies a request, and replays it.

Authentication in a first-party API is a category error waiting to happen. The word first-party describes who owns the client, not who controls the request. Once a payload leaves the browser, it belongs to whoever can read it, modify it, and send it again. The mistakes below are not exotic. They are the ordinary ones, repeated because the architecture feels safe enough to skip the boring parts.

Storing the token in the obvious place

The path of least resistance is to drop the session token into localStorage and read it on every fetch. It works, it survives a refresh, and it takes about four lines. It also hands the token to any script that ends up on the page, whether from a compromised dependency, an injected ad tag, or a third-party widget someone added for analytics.

The common defense is to move the token into an HttpOnly cookie so JavaScript cannot read it. That closes one door and opens another: the browser now attaches the credential automatically, and cross-site requests can ride along unless the cookie is scoped with SameSite and the server checks origin. A reader who has done this work knows the shape of the tradeoff. There is no placement that is free. The decision is which attack is more likely for this product: script injection or cross-site request forgery.

For a first-party API serving a logged-in dashboard, the stronger default is the HttpOnly cookie with SameSite set to Lax or Strict, paired with a CSRF token on state-changing calls. The point is not that cookies are safer in the abstract. It is that the choice should be made on purpose, written down, and revisited when the client changes.

Treating the client secret as a client secret

A mobile app or a single-page application ships its code to the device. Any key embedded in that bundle is public, regardless of what the file is called. The label "client secret" has led more teams astray than any other phrase in this space, because it suggests a confidentiality that does not exist on a device the user controls.

The fix is to stop giving the client a secret at all. Public clients should use authorization code flow with PKCE, which binds the code exchange to a value the client generates and keeps, so an intercepted code cannot be redeemed elsewhere. The client identifies itself; it does not prove itself with a shared password. That is the correct model for anything a person can install or inspect.

Backend services are a different case. A server-to-server call can hold a real secret because the code never leaves the server. Mixing the two models is where things go wrong: a secret created for a backend job gets pasted into a frontend config because it was already there, and now it is in a bundle on the internet.

Trusting the token's claims without checking them

A JSON Web Token carries claims, and a surprising number of services read those claims and act on them without verifying the signature. The token says the user is an admin, so the service believes it. Anyone who can craft a token with the right shape gets the same treatment.

Verification means the signature is checked against the expected key, the algorithm is pinned to what the issuer actually uses, and the standard claims are validated: issuer, audience, expiry, and not-before. Skipping the audience check is the subtle one. A token minted for one service is then accepted by another because both trust the same issuer, and a low-privilege token from the first service becomes a credential for the second.

There is also the alg=none trick, where a token declares that it is unsigned and a naive verifier accepts it. Libraries have been patched for this for years, but the pattern persists in hand-rolled verification code. If the verification step was written from scratch, it deserves a second reader.

Putting authorization logic in the client

The frontend hides the delete button for non-admins. That is a user interface decision, and it is fine as far as it goes. The mistake is assuming the hidden button means the endpoint is protected. The endpoint is a URL. Anyone with a session can call it directly, and if the server checks only that a valid token exists, the delete happens.

Every endpoint that changes state needs its own authorization check, evaluated on the server, against the specific resource being touched. Not "is this user logged in" but "is this user allowed to modify this record." The second question is the one that catches the horizontal privilege escalation, where a valid user changes an ID in the request and reaches another user's data. First-party APIs are especially prone to this because the client always sends the right ID, so the server never learns to question it.

Letting tokens live forever

A token with no expiry is a password that never changes and cannot be revoked without a redeploy. The convenient version, a session token valid for thirty days, is barely better. If it leaks, the window to use it is measured in weeks.

Short-lived access tokens paired with refresh tokens are the standard answer, and the detail that matters is rotation. Each refresh issues a new refresh token and invalidates the old one. If an old refresh token is presented again, that signals theft, and the whole session family should be revoked. This is the part teams skip because it requires server-side state, and server-side state is the thing they were trying to avoid by using tokens in the first place.

The state is worth it. A refresh token table with a family identifier and a used flag is not a large amount of work, and it turns a silent compromise into a detectable one.

Skipping rate limits on the login endpoint

Login is the one endpoint where an attacker gets unlimited attempts for free unless someone stops them. The first-party client makes exactly one request per login, so the server sees normal traffic and nobody notices the endpoint has no throttle. Then a script starts guessing passwords at a few hundred per minute and the logs fill with failures that look like typos.

Rate limiting belongs on login, on password reset, and on any endpoint that mints a token. It should be keyed by more than IP address, because IPs are cheap to rotate, and it should fail closed rather than logging and continuing. A simple per-account counter with exponential backoff covers most of the risk. The cost of adding it is a few hours. The cost of not adding it is an account takeover that looks like a normal login in the access logs.

Logging the credential

Request logging is essential, and the default configuration often captures headers, query strings, and bodies. That means the Authorization header lands in a log aggregator, the refresh token lands in a URL, and the password reset token lands in a support ticket. The logs are then shipped to a third-party service with weaker access controls than the API itself.

The rule is to redact known credential fields at the logging layer, before the record leaves the process. A list of header names and body keys, applied centrally, is enough. The mistake is doing it per handler, because the handler added next quarter will forget. First-party APIs tend to log more aggressively than public ones, on the theory that the traffic is internal. It is internal right up until the log vendor has a breach.

Assuming HTTPS closes the loop

TLS protects the wire. It does not protect the browser, the device, or the endpoint. A first-party API behind a valid certificate can still be called with a stolen token from a laptop on the other side of the world, and the transport layer will not notice. Encrypting the pipe is necessary and nowhere near sufficient.

The useful mental shift is to treat every request as arriving from an untrusted source, including the ones that look like they came from the app. The client is a convenience layer. The server is where trust is decided, and the only thing the server should trust is a credential it has verified itself, scoped to the specific action being requested.

That posture is unglamorous and it costs a little latency and a little code. It also means the next time someone opens the network tab, the request they replay gets rejected for a reason the server can explain.

Leave a Reply

Your email address will not be published. Required fields are marked *

Back to top button