DEV Community

Cover image for Keep the steering wheel: 3 ways to future-proof your auth
Bilaniuc Dragos
Bilaniuc Dragos

Posted on • Edited on

Keep the steering wheel: 3 ways to future-proof your auth

Your auth library's defaults quietly become your architecture. Here's how to stay in control while still renting the hard parts from people who specialize in them.

Auth is the ultimate hot path

Most components in your system sit on one critical path. Authentication sits on three at once.

It's the entry point: the first thing a user touches, where friction costs you people before they're even inside the product. It's on the critical path of every authenticated API call: every request carries a credential, so auth's latency, reliability, and cost are levied on all of them. And it's the highest-value security target in the system, the one component where a single flaw doesn't degrade something — it compromises everything.

Picking the right vendor for your requirements matters, obviously. But even with the ideal vendor, there's a hidden risk. When you adopt a vendor auth library, you outsource the implementation, which is good — you should. But you also silently outsource the architecture. The library's defaults become load-bearing walls you never decided to build. By the time you want to move one, you discover the whole house is resting on it.

A note on scope before we start: this piece is about the client side of auth — SPAs and micro-frontends, token storage, refresh, the seam between your frontend code and a managed identity provider. The principles travel, but the examples live in the browser.

How auth calcifies

In a previous article I walked through a real situation: a penetration test flagged that our auth tokens were stored where client-side JavaScript could read them, and we deliberately accepted the vulnerability rather than fixing it immediately. Not out of negligence. The auth system hadn't been built with change in mind, so what should have been a configuration change had calcified into a months-long re-architecture touching multiple micro-frontends and 100+ backend services that consume those tokens.

I made a claim in that piece: that "do nothing for now" actually meant doing a lot of quiet foundation work — the kind that turns a future migration from a re-architecture into a flipped switch.

This article is that foundation work: three ways to keep auth changeable. It applies whether you're starting greenfield, where building it in costs almost nothing, or running a large production system, where you do it proactively, in the windows where you have bandwidth, ahead of a move you can see coming. In the second case there's a bonus: making auth changeable forces you to understand the system you already have, in ways that pay off well beyond auth.

Way 1: Own the boundary (and make it more than a facade)

Create your own auth interface — but make sure it isn't a leaky facade. That's the common trap: a lib/auth.ts that re-exports the vendor's functions but still returns the vendor's session object, throws the vendor's errors, and uses the vendor's claim names. The vendor's shape passes straight through to every call site. You've added a file, not a boundary.

A boundary that actually future-proofs you does three things a facade doesn't.

1. It's an anti-corruption layer. It speaks your domain's language — your Session, Principal, AuthError — and the vendor's types never cross it.

// ❌ Leaky: the vendor's shape escapes into every caller.
export const getSession = () => fetchAuthSession(); // returns Amplify's AuthSession

// ✅ Bounded: callers only ever see your types.
export interface AuthPort {
  getAuthHeaders(): Promise<Record<string, string>>;
  getPrincipal(): Promise<Principal | null>;
  onRefresh(): Promise<boolean>;
  onSessionExpired(): void;
}
Enter fullscreen mode Exit fullscreen mode

2. Capabilities are injected, not imported. The common wrapper is an imported singleton, so every call site is hard-wired to one implementation. That makes it impossible to swap per context and untestable without mocking modules.

// ❌ Imported singleton: every call site hard-wired to one implementation.
import { getToken } from "@/lib/auth";
const res = await fetch(url, { headers: { Authorization: `Bearer ${await getToken()}` } });
Enter fullscreen mode Exit fullscreen mode

Inject the capability instead. Now you can swap it per execution context (client, server, test) and stand up your whole data layer against a fake in tests, with zero network:

// ✅ Injected: the client depends on the AuthPort contract, not a concrete import.
export function createApiClient(auth: AuthPort) {
  return async function apiFetch<T>(url: string, init: RequestInit = {}): Promise<T> {
    const call = async () =>
      fetch(url, { ...init, headers: { ...init.headers, ...(await auth.getAuthHeaders()) } });

    let res = await call();
    if (res.status === 401 && (await auth.onRefresh())) res = await call(); // refresh → retry
    if (res.status === 401) { auth.onSessionExpired(); throw new Error("Session expired"); }
    return res.json();
  };
}
Enter fullscreen mode Exit fullscreen mode

(What onRefresh actually does is the subject of Way 3. For now, notice that it's a slot. The call sites don't know or care.)

3. The boundary is contract-tested. This is the part almost nobody does, and it's what makes "future-proof" verifiable instead of aspirational. Because the boundary is an explicit interface, you write one conformance suite, and every adapter you ever write must pass it:

// auth-port.contract.ts
// Run the SAME suite against every adapter: Amplify today, Auth0 tomorrow,
// the in-memory fake your tests use forever.
export function runAuthContractTests(
  name: string,
  makeAdapter: (scenario: TestScenario) => AuthPort,
) {
  describe(`AuthPort contract: ${name}`, () => {
    test("returns a bearer header when authenticated", async () => {
      const auth = makeAdapter({ tokenState: "valid" });
      const headers = await auth.getAuthHeaders();
      expect(headers.Authorization).toMatch(/^Bearer /);
    });

    test("returns no principal when unauthenticated", async () => {
      const auth = makeAdapter({ tokenState: "absent" });
      expect(await auth.getPrincipal()).toBeNull();
    });

    test("recovers from one expired token via onRefresh", async () => {
      const auth = makeAdapter({ tokenState: "expired", refreshOutcome: "success" });
      expect(await auth.onRefresh()).toBe(true);
      const headers = await auth.getAuthHeaders();
      expect(headers.Authorization).toMatch(/^Bearer /); // new token, same contract
    });

    test("signals expiry exactly once when refresh fails", async () => {
      const onExpired = vi.fn();
      const auth = makeAdapter({
        tokenState: "expired",
        refreshOutcome: "failure",
        onSessionExpired: onExpired,
      });
      expect(await auth.onRefresh()).toBe(false);
      auth.onSessionExpired();
      expect(onExpired).toHaveBeenCalledTimes(1);
    });

    test("dedupes concurrent refreshes into a single underlying call", async () => {
      const scenario: TestScenario = { tokenState: "expired", refreshOutcome: "success" };
      const auth = makeAdapter(scenario);
      await Promise.all([auth.onRefresh(), auth.onRefresh(), auth.onRefresh()]);
      expect(scenario.refreshCallCount).toBe(1); // see Way 3
    });
  });
}

// Then each adapter is one line of registration:
runAuthContractTests("amplify", (s) => makeAmplifyAdapter(s));
runAuthContractTests("in-memory fake", (s) => makeFakeAdapter(s));
Enter fullscreen mode Exit fullscreen mode

This changes what a migration is. It stops being "swap it and pray across 100 call sites" and becomes "make the new adapter green." Your migration now has a definition of done — and your test fake is held to the same standard as production, so green tests against the fake actually mean something.

Way 1 — Ports and adapters: app code depends on your AuthPort; vendors are swappable adapters behind it

The boundary is the shape of future-proof auth. The next two ways are about drawing it correctly and filling in its hardest piece.

Way 2: Know your tools deeply enough to replace them

You can't draw a good boundary around something you don't understand. And you don't understand an auth library by reading its quickstart — you understand it by extending it until its machinery is visible.

One high-leverage exercise: replace its token storage. Even if you only reproduce what the library already does, the act forces every question that matters. What's holding my tokens right now? Where else could they live — localStorage, a cookie, an HttpOnly cookie, memory? What are the tradeoffs? How would I switch later? Done with a clean, well-documented adapter, the result is a concise artifact your whole team benefits from: the storage mechanism explicit and in front of you, instead of buried in vendor docs and guesswork.

Every serious library exposes this seam, and learning where is half the lesson:

// Amplify v6 — pluggable key-value storage for tokens.
import { cognitoUserPoolsTokenProvider } from "aws-amplify/auth/cognito";

cognitoUserPoolsTokenProvider.setKeyValueStorage({
  async setItem(k, v) {/* cookie? memory? your call */},
  async getItem(k) {/* ... */ return null;},
  async removeItem(k) {/* ... */},
  async clear() {/* ... */},
});
Enter fullscreen mode Exit fullscreen mode
  • Auth0's SPA SDK takes a custom cache implementing get/set/remove.
  • Firebase lets you select among persistence strategies (browserLocal, browserSession, indexedDB, inMemory) via setPersistence.

Different surfaces, same underlying seam. Find it in one library and you know what to look for in the next.

Storage is just the lead example. The same "extend it to understand it" move applies elsewhere: swapping in a custom HTTP client, hooking the token-refresh cycle, subscribing to the library's auth lifecycle events, plugging in a custom credentials provider. Each one makes a different part of the machine visible.

The deliverable isn't the adapter. It's the understanding — the thing that lets you write an adapter you trust, and recognize in advance which of the vendor's defaults are about to become your walls.

Way 3: Own your critical runtime behaviors, starting with refresh

Token refresh is where vendor magic most quietly becomes load-bearing. In our Amplify setup, calling fetchAuthSession() refreshed tokens silently and automatically. Very convenient — until you look closer and notice the convenience depends on the tokens living in storage the browser's JavaScript can read, the same client-accessible storage that's open to XSS exfiltration. (That's the root problem behind the first article.) The moment you move tokens into HttpOnly cookies to close that hole, client code can no longer read them, and the silent client-side refresh you built everything on stops working. All at once, everywhere.

Own the behavior instead, so a change like that touches one place. Refresh has three parts worth understanding rather than inheriting: when you refresh, how you avoid stampedes, and what happens when it fails.

The robust shape is reactive — refresh on a 401, then retry — with single-flight deduplication, so that ten concurrent 401s trigger one refresh, not ten:

let inflight: Promise<boolean> | null = null;

export function refreshOnce(doRefresh: () => Promise<boolean>): Promise<boolean> {
  inflight ??= doRefresh().finally(() => { inflight = null; });
  return inflight; // every concurrent caller awaits the same refresh
}
Enter fullscreen mode Exit fullscreen mode

That's the onRefresh slot from Way 1, finally filled in. Owned by you, swappable behind the boundary, and held to the contract suite (that's what the "dedupes concurrent refreshes" test was checking).

Way 3 — reactive token refresh

Refresh is the headline example, but the principle generalizes to every behavior the vendor does invisibly: multi-tab session sync, sign-out propagation, silent re-auth. None of these are wrong to inherit. On a hot path, though, you want to know you've inherited them, and be able to take the wheel when you need to.

Where the three ways point

Notice that none of the three is about swapping your identity provider, yet together they push you toward it. A boundary that speaks OIDC and OAuth2 rather than vendor-isms, an understanding deep enough to write a conforming adapter, and volatile behaviors you own behind the seam all pull in the same direction: making the IdP closer to configuration than architecture.

You won't get there entirely for free. Vendor-specific features (Cognito groups, custom attributes, admin APIs) leak, and the more you lean on them, the more coupling stays behind. But these steps localize that coupling to one adapter instead of spreading it across the codebase. If you do reach the point where switching providers means writing a new adapter and making it pass your contract suite, that's one of the most valuable decoupling points a system can have.

Where the line sits: rent vs. own

If you're doing all this, a reasonable question is whether the vendor is earning its keep at all. Why not drop it and own the whole thing?

Because the vendor is doing the genuinely hard, genuinely dangerous work you do not want to own:

  • Cryptographic correctness — password hashing, token signing, PKCE, constant-time comparisons, the dozen ways to get this subtly and catastrophically wrong.
  • The edge-case swamp — MFA, account recovery, brute-force lockouts, credential-stuffing detection, bot defense. Each is a project.
  • Compliance and certification — SOC 2 and the security audits a managed provider passes so you don't have to.
  • The 24/7 operational burden — auth is a hot path, so if your hand-rolled auth goes down, everything goes down. Now it's in your on-call rotation forever, against an adversary whose techniques evolve weekly.

In the previous article, the single most decisive argument against one of the candidate architectures was exactly this: it would have meant owning the entire auth substrate — a new system of record, patched and scaled and on-call'd by us instead of by people who do only that. That cost sank the option.

Rent the engine. Own the steering.

The line has another side, and falling off it is just as expensive. Take "future-proof" too far — abstract everything, build seams nobody will ever flex, reproduce half the vendor's library "just in case" — and you land somewhere worse than where you started: over-built for today and still wrong for tomorrow, because the future arrived in a shape you didn't predict.

The discipline is to build seams only where change is both likely and expensive to retrofit. Notice what you're betting on: which axes will move, not how. You don't need to know whether you'll move to HttpOnly cookies or switch providers, only that those axes are live and worth making cheap to change in any direction.

A seam is cheaper than a prediction.

Flexibility everywhere is its own kind of rigidity. The skill is choosing where to be flexible.

The agentic era cuts both ways

The calcification trap predates AI, but coding agents make it faster and more pervasive, for a structural reason. An agent reaches for the vendor's documented happy path: import { signIn } from "vendor", used in-line, no boundary, vendor-magic refresh. It optimizes for "compiles and ships," not "still changeable in two years," and it won't weigh changeability unless you tell it to. The patterns that calcify now get generated at volume, often by people scaffolding auth they don't fully understand. What used to happen one developer at a time now happens across a codebase before anyone makes a conscious architectural decision.

But the same agent that defaults to the calcified version is also the cheapest way you've ever had to build the resilient one. The AuthPort, the adapter, the conformance suite, the single-flight refresh — the mechanical work that used to be the reason teams skipped the boundary ("no time for all that abstraction") — is now nearly free to generate. The cost that justified cutting the corner has collapsed.

So the balance tips toward doing the three ways, not away. The judgment stays yours: where the seams belong, which axes are live, what's likely and expensive enough to bound. The typing is the agent's. The newly essential discipline is encoding your intent so the agent stays inside it — a boundary spec it builds against, the contract suite as its target, lint rules that fail the build when a vendor type leaks past the line.

Takeaways

  • A wrapper isn't a boundary. If the vendor's types, errors, and claim names cross your abstraction, you've built a leaky facade. Speak your own domain's language and keep the vendor on one side of the line.
  • Inject auth as a capability, not an imported singleton, so it's swappable per context and your data layer is testable with zero network.
  • Contract-test the boundary. One conformance suite per interface turns "future-proof" into a definition of done for any future migration.
  • Understand your library by extending it. Replacing token storage is the highest-leverage exercise, and the result is an explicit artifact your team can read instead of guess at.
  • Own the volatile runtime behaviors, refresh first, with single-flight dedup and an explicit failure path. Know what you've inherited even when you choose to keep it.
  • Future-proofing is not building auth yourself. Rent the crypto, the edge cases, the compliance, the on-call. Own the seams and the judgment.
  • Don't over-rotate. Build seams only where change is both likely and expensive to retrofit. Bet on which axes move, not how.
  • Agents accelerate calcification — but also the fix. They default to the vendor's calcified happy path at scale, but make the boundary nearly free to build. Supply the architectural intent; encode it as specs, contract tests, and lint rules the agent can't route around.

Closing

This is part two of a short series on auth architecture in production. Part one is the narrative: how an under-designed auth system cornered a large platform into accepting a known vulnerability, and why that was the right call given the constraints. This piece is the constructive flip side — the foundation work that keeps you out of that corner in the first place.

Next in the series: I'm building an agent skill that audits a codebase for exactly the failures described here — missing boundaries, leaking vendor types, inherited refresh — and proposes a backlog to fix them. Follow along if that's useful.

And if your team is about to make an expensive-to-reverse decision in auth, cloud, or platform architecture and wants an independent second pair of eyes, that's the kind of work I take on through Luckylabs. Reach out if it sounds useful.

Top comments (0)