Authentication
Tokens, cookies, and the interceptors you’ll actually edit.
Auth is where generated clients either save you a week or quietly attach Bearer undefined forever. chowbea-axios generates an interceptor skeleton; you decide what “logged in” means.
api.instance.ts is generated once. After that it’s your file. Re-running fetch will not lovingly overwrite your refresh queue — though it may warn if [instance] config drifted.
auth_mode
| Mode | What you get |
|---|---|
bearer-localstorage | Reads localStorage[token_key], attaches Authorization: Bearer … |
custom (default) | Pass-through interceptor with a TODO — implement your store |
none | No auth interceptor. Refreshing purity. |
[instance]
auth_mode = "bearer-localstorage"
token_key = "auth-token"chowbea-axios init --auth-mode bearer-localstorage --token-key auth-tokenWhat bearer-localstorage actually parses
axiosInstance.interceptors.request.use((config) => {
if (typeof window === "undefined") return config;
const raw = localStorage.getItem("auth-token");
if (!raw) return config;
try {
const parsed = JSON.parse(raw);
const token = parsed.state?.token ?? parsed.token ?? parsed;
if (typeof token === "string") {
config.headers.Authorization = `Bearer ${token}`;
}
} catch {
config.headers.Authorization = `Bearer ${raw}`;
}
return config;
});Handles plain strings, { token }, and Zustand-persist { state: { token } }. If your store is weirder, use custom.
The custom pattern you’ll ship
This is the production shape: read from a store, attach the header, refresh on 401 without stampedes.
import axios from "axios";
import { axiosInstance } from "./api.instance";
// your app:
// import { useAuthStore } from "@/stores/auth";
axiosInstance.interceptors.request.use((config) => {
const token = useAuthStore.getState().accessToken;
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
let refreshing = false;
let queue: Array<(token: string | null) => void> = [];
function flushQueue(token: string | null) {
queue.forEach((cb) => cb(token));
queue = [];
}
axiosInstance.interceptors.response.use(
(res) => res,
async (error) => {
const original = error.config;
const status = error.response?.status;
const url = String(original?.url ?? "");
// Don't refresh the refresh (or login) endpoint
if (status !== 401 || original._retry || /\/auth\/(login|refresh)/.test(url)) {
return Promise.reject(error);
}
if (refreshing) {
return new Promise((resolve, reject) => {
queue.push((token) => {
if (!token) return reject(error);
original.headers.Authorization = `Bearer ${token}`;
resolve(axiosInstance(original));
});
});
}
original._retry = true;
refreshing = true;
try {
const refreshToken = useAuthStore.getState().refreshToken;
const { data } = await axios.post("/auth/refresh", { refreshToken });
useAuthStore.getState().setTokens(data);
flushQueue(data.accessToken);
original.headers.Authorization = `Bearer ${data.accessToken}`;
return axiosInstance(original);
} catch (refreshError) {
flushQueue(null);
useAuthStore.getState().clear();
window.location.href = "/login";
return Promise.reject(refreshError);
} finally {
refreshing = false;
}
},
);Wire the store however you like (Zustand persist, context, cookie jar). The point is: one place owns tokens, and concurrent 401s wait in line instead of starting a refresh festival.
Cookies
[instance]
with_credentials = trueDefault is false. Flip it when your API lives on another origin and authenticates with cookies — and make sure CORS agrees, or enjoy an afternoon of network-tab archaeology.
API keys
axiosInstance.interceptors.request.use((config) => {
config.headers["X-API-Key"] = import.meta.env.VITE_API_KEY;
return config;
});Fetching a protected OpenAPI spec
Runtime auth ≠ spec-fetch auth. For a locked swagger URL:
[fetch.auth]
type = "basic"
username = "$SWAGGER_USER"
password = "$SWAGGER_PASS"
# Optional extra headers for the spec download only
[fetch.headers]
"X-Internal-Token" = "$SPEC_TOKEN"export SWAGGER_USER="ci-bot"
export SWAGGER_PASS="..."
chowbea-axios fetchinit can scaffold .github/workflows/chowbea-axios-ci.yml with secrets wiring. Pass --skip-workflow if CI is already your religion.
Handling UNAUTHORIZED in app code
const { error } = await api.op.getMe();
if (error?.code === "UNAUTHORIZED") {
// interceptor may already be refreshing; this is the UI fallback
}Prefer fixing 401s in the interceptor. Components should not each invent logout logic.