Back to writing
Authentication · 9 min read

Designing Secure Authentication APIs

A secure authentication API is a protocol boundary, not a login controller. Token semantics, failure behavior, rotation, and agent identity must be designed explicitly.

Many authentication APIs are basically one controller with very high confidence. Accept email and password, compare a hash, sign a JWT, return 200. Add HTTPS and rate limiting, then call it production ready. The endpoint may be only fifty lines, but those fifty lines are now issuing proof that every other service will trust. That is not a normal controller. It is a security protocol boundary, full stop.

What I am seeing is that teams are reviewing authentication APIs like business APIs. Does the input validate. Does the database query work. Does the happy path return the right JSON. Those questions matter, but the dangerous behavior is usually between requests: what an attacker can learn from failures, whether an old refresh token still works, which audience accepts the token, and what happens after the account is disabled.

The endpoint is smaller than the protocol

Consider the naive shape:

app.post("/login", async (req, res) => {
  const user = await users.findByEmail(req.body.email);
 
  if (!user) return res.status(404).json({ error: "user not found" });
  if (!await verifyPassword(req.body.password, user.passwordHash)) {
    return res.status(401).json({ error: "wrong password" });
  }
 
  const token = signJwt({ userId: user.id, role: user.role });
  return res.json({ token });
});

It works. It also reveals which email addresses exist, gives every resource server the same vaguely defined token, copies authorization into a credential with no freshness model, and has no visible session or refresh-token lifecycle. The bug is not one missing if statement. The API has no protocol around it.

A naive login endpoint becomes an uncontrolled credential factory

Authentication design starts before password verification and continues after token issuance. Treat enrollment, recovery, MFA, login, refresh, logout, and revocation as one connected system. An account-recovery flow that bypasses MFA can defeat a perfectly implemented login endpoint. A refresh endpoint that accepts replay forever can undo a short access-token lifetime also.

Failure responses are part of the attack surface

Different errors make debugging pleasant and account enumeration easy. "User not found" and "incorrect password" tell an attacker whether the first guess was correct. Different response times can reveal the same thing even when the JSON is identical.

For public login and recovery endpoints, return a uniform external response for invalid credentials or unknown accounts. Internally, log the actual reason with appropriate access controls. When the account does not exist, perform a bounded password-hash operation using a fixed dummy hash so the timing shape stays reasonably close.

Rate limiting needs more than an IP address. Attackers distribute traffic, and shared corporate networks put many legitimate users behind one IP. Combine controls by source, account identifier, device or client signal, and overall risk. Apply progressive delay or step-up checks where appropriate. Do not turn rate limiting into a denial-of-service button where anyone can lock a victim's account by sending bad passwords.

The response should not explain your internal identity state. The audit trail should.

A token needs one meaning

"It is a valid JWT" is not a security decision. A JWT is a container. The resource server must know who issued it, which audience it targets, which algorithms are allowed, what token type it represents, and whether its time bounds are acceptable.

RFC 8725, the JSON Web Token Best Current Practices document, is clear about algorithm verification and cross-JWT confusion. An ID token should not accidentally work as an API access token. A token minted for the billing API should not work at the administration API just because both services know the same signing key.

Validation should be explicit:

const claims = await jwtVerify(rawToken, signingKey, {
  issuer: "https://identity.example.com",
  audience: "https://api.example.com/invoices",
  algorithms: ["ES256"],
  typ: "at+jwt",
  clockTolerance: 30,
});
 
if (claims.payload.token_use !== "access") {
  throw new Error("unexpected token type");
}
 
if (!hasRequiredScope(claims.payload.scope, "invoices:read")) {
  throw new Error("insufficient scope");
}

Do not let the token choose its own verification algorithm. Do not accept every audience your company owns. Do not make a resource server understand login-session cookies, ID tokens, access tokens, and internal agent tokens through one permissive parser. Separate token profiles make rejection easier to reason about.

Short access tokens need a real refresh design

Short-lived access tokens reduce the useful window after theft, but only if the refresh token is handled as the more powerful credential it is. A refresh token that lives for months, can be replayed repeatedly, and is stored beside the access token has not reduced much risk actually.

Rotate refresh tokens on every successful use. Store a hash of the current token, link tokens into a family, and treat reuse of an already-consumed token as a replay signal. Revoke the active family and require reauthentication rather than issuing one more access token to whichever party arrived second.

async function exchangeRefreshToken(presented: string) {
  const digest = hashToken(presented);
 
  return tokenStore.transaction(async (tx) => {
    const record = await tx.findForUpdate(digest);
 
    if (!record || record.revokedAt || record.expiresAt < Date.now()) {
      throw new Error("invalid refresh token");
    }
 
    if (record.consumedAt) {
      await tx.revokeFamily(record.familyId, Date.now());
      throw new Error("refresh token replay detected");
    }
 
    await tx.markConsumed(record.id, Date.now());
    return tx.issueSuccessor(record.familyId, record.subjectId);
  });
}

The transaction is important. Two refresh requests racing each other should not both receive valid successors. One succeeds. The other becomes evidence that the credential may have been copied.

A secure authentication API keeps token issuance inside explicit controls

Agents are not unusual users

When an AI agent needs an API, teams often send it through the human login endpoint using a service account. That creates a credential which looks like a person, has no delegation chain, and usually carries broad permissions because nobody can predict every task the agent may attempt.

Give workloads their own client identity. Use an OAuth grant appropriate for the actor, such as client credentials for the agent's own authority or token exchange from RFC 8693 when it acts on behalf of a user. Bind the resulting access token to one audience and narrow scopes. Keep the human subject and agent identity distinguishable in logs.

An agent session also needs a different lifetime. A browser may remain signed in for convenience, while an agent task should receive a credential bounded to the task and expire when the work should stop. Reusing the browser token is easy, but it makes logout, audit, and least privilege much harder only.

For high-risk machine-to-machine calls, sender-constrained tokens can reduce bearer-token theft. Mutual TLS and proof-of-possession approaches add operational work, so use them where the threat justifies it, but understand the tradeoff. A bearer token belongs to whoever holds it.

Recovery and revocation are authentication APIs too

Password reset, account recovery, device removal, and session revocation are sometimes built later by a different team. Attackers do not respect those ownership boundaries. They use whichever path produces trust most cheaply.

Recovery tokens should be single use, short lived, stored hashed, and scoped to the exact recovery action. Completing recovery should invalidate relevant sessions and refresh-token families. Changing a password without touching already issued credentials leaves the attacker signed in.

Revocation behavior needs a measurable target. If disabling an account must stop sensitive access within five minutes, access-token lifetimes, caches, long-lived connections, and background jobs must honor that window. Writing a disabled flag into the user table is not revocation until the services check it.

Audit every credential transition: issuance, failed authentication, MFA challenge, refresh rotation, replay detection, recovery, and revocation. Avoid logging raw passwords, session IDs, access tokens, or recovery secrets. An audit system that captures the credential itself becomes another credential store.

The takeaway

A secure authentication API does more than verify a password and sign a token. It controls what failures reveal, gives each token one precise meaning, detects refresh replay, and carries revocation through the rest of the architecture.

Treat human sessions, service identities, and delegated agent access as different credential flows. They can share identity infrastructure, but they should not share one ambiguous token. The API is issuing trust that other systems will act on, and that trust needs a protocol around it, not just a controller.

Akash Devdhar is a Senior Software Engineer specializing in enterprise identity, authentication, authorization, and AI infrastructure. He writes about building secure AI systems using OAuth, OIDC, RBAC, and modern identity architectures.