Old Versions and Deprecation
Every version you keep alive is a full copy of your attack surface that receives less attention than the current one.
Versioning is a compatibility feature with a security cost: v1 is an entire API that nobody is looking at. The fix that went into v2 usually did not go into v1, and the pentest scope said "the API", which everyone read as the current one.
What goes wrong in old versions
- Security fixes applied only to the current version.
- Weaker authentication that was tightened later.
- Responses that expose fields the new serializer removed.
- Missing rate limits, because they were added with v2.
- Missing from monitoring, alerting and the WAF rule set.
Deprecation needs a date
"Deprecated" without a sunset date means "supported forever". Publish the date when you deprecate, communicate it repeatedly, and hold it. The Deprecation and Sunset HTTP headers (RFC 8594) let clients discover it programmatically.
Know who is still calling
You cannot retire a version whose users you cannot name. Log the version and the client on every request, so "can we turn v1 off?" has an answer that is a list of accounts rather than a guess.
Brownouts
Before a sunset, return errors for the old version during short scheduled windows. It surfaces clients who ignored every email while there is still time to react — far better than discovering them on the day you switch it off.
Or: do not version the whole API
Additive changes rarely need a version. New optional fields, new endpoints and new enum values can be backwards compatible. Version the few resources that genuinely break, not the whole surface, and you have far less to keep secure.
Example
# RFC 8594 — tell clients programmatically, not only in a blog post
HTTP/1.1 200 OK
Deprecation: Sat, 01 Jan 2026 00:00:00 GMT
Sunset: Thu, 31 Dec 2026 23:59:59 GMT
Link: <https://docs.dfg.com/migrations/v1-to-v2>; rel="deprecation"
Warning: 299 - "This API version is deprecated and will be removed on 2026-12-31"
{ "data": [...] }
# A client library that reads these can warn its own developers long before
# the sunset, which is the entire point of standardising them.When to use it
- A BOLA fixed in v2 remains exploitable in v1, which was still deployed for one legacy client and outside the pentest scope.
- A scheduled brownout surfaces three integrations still on v1 two months before the sunset date, rather than on the day.
- Per-version request logging turns 'can we retire v1?' into a list of four named accounts to contact.
More examples
Tracking usage so retirement is possible
Attaching a contact to each remaining client is what turns the report into a retirement plan rather than a metric nobody acts on.
// You cannot retire what you cannot measure. Record version and client on
// every request, and make the report self-service.
app.use((req, res, next) => {
const version = req.path.match(/^\/api\/(v\d+)\//)?.[1] ?? 'current';
req.apiVersion = version;
res.on('finish', () => {
metrics.increment('api.request', {
version,
client: req.client?.id ?? 'unknown',
route: req.route?.path ?? 'unmatched',
});
if (version !== 'current') {
// Per-account, per-day — this is the list you will need.
redis.pfadd(`v-users:${version}:${today()}`, req.user?.id ?? req.ip);
redis.expire(`v-users:${version}:${today()}`, 90 * 86400);
}
});
next();
});
// Deprecation headers, applied by the router so no route can forget them.
const DEPRECATED = {
v1: { deprecatedAt: '2026-01-01', sunsetAt: '2026-12-31',
migration: 'https://docs.dfg.com/migrations/v1-to-v2' },
};
app.use('/api/:version', (req, res, next) => {
const info = DEPRECATED[req.params.version];
if (!info) return next();
res.set({
'Deprecation': new Date(info.deprecatedAt).toUTCString(),
'Sunset': new Date(info.sunsetAt).toUTCString(),
'Link': `<${info.migration}>; rel="deprecation"`,
'Warning': `299 - "This version will be removed on ${info.sunsetAt}"`,
});
// After the sunset, refuse — clearly, and with a way forward.
if (new Date() > new Date(info.sunsetAt)) {
return res.status(410).json({
error: 'api_version_removed',
detail: `${req.params.version} was removed on ${info.sunsetAt}.`,
migration: info.migration,
});
}
next();
});
// The report that makes the decision, with names rather than numbers
export async function versionUsageReport(version) {
const days = last30Days();
const uniques = await redis.pfcount(...days.map((d) => `v-users:${version}:${d}`));
const clients = await db('api_requests')
.where({ version }).andWhere('created_at', '>', daysAgo(30))
.select('client_id').count('* as calls')
.groupBy('client_id').orderBy('calls', 'desc');
return {
version,
uniqueCallers: uniques,
canRetire: uniques === 0,
clients: await Promise.all(clients.map(async (c) => ({
...c,
contact: await contactFor(c.client_id), // ← so someone can email them
lastCall: await lastCallFor(c.client_id, version),
}))),
};
}Brownouts: find the stragglers before the deadline
Notifying on first contact during a brownout closes the loop that email campaigns miss — it reaches whoever is actually operating the integration.
// A brownout returns errors for the deprecated version during short scheduled
// windows. Clients who ignored every email discover the problem while there is
// still time — which is much better for both sides than the sunset day.
const BROWNOUT_SCHEDULE = [
// Start small and escalate as the date approaches.
{ date: '2026-09-01', durationMinutes: 5, percent: 100 },
{ date: '2026-10-01', durationMinutes: 30, percent: 100 },
{ date: '2026-11-01', durationMinutes: 120, percent: 100 },
{ date: '2026-12-01', durationMinutes: 480, percent: 100 },
// 2026-12-31: permanent
];
function inBrownout(now = new Date()) {
for (const window of BROWNOUT_SCHEDULE) {
const start = new Date(`${window.date}T10:00:00Z`); // business hours, on purpose
const end = new Date(start.getTime() + window.durationMinutes * 60_000);
if (now >= start && now < end) return window;
}
return null;
}
app.use('/api/v1', async (req, res, next) => {
const window = inBrownout();
if (!window) return next();
// Notify the caller's owner the FIRST time they hit a brownout.
const clientId = req.client?.id ?? req.user?.id;
if (clientId && await redis.set(`brownout-notified:${clientId}:${window.date}`,
'1', 'EX', 86400, 'NX')) {
await notifyClientOwner(clientId, {
subject: 'Your integration still uses API v1',
detail: `v1 is removed on 2026-12-31. Today's scheduled brownout lasted ` +
`${window.durationMinutes} minutes; the next one is longer.`,
migration: 'https://docs.dfg.com/migrations/v1-to-v2',
});
}
logger.warn({ clientId, path: req.path }, 'brownout: v1 request refused');
res.set('Sunset', new Date('2026-12-31').toUTCString());
return res.status(503).json({
error: 'scheduled_brownout',
detail: 'API v1 is being retired. This is a scheduled brownout.',
sunsetAt: '2026-12-31',
migration: 'https://docs.dfg.com/migrations/v1-to-v2',
nextBrownout: nextWindow()?.date,
});
});
// Scheduling brownouts during business hours is deliberate: you want the
// client's engineers awake and at their desks when it happens.Avoiding versions in the first place
The dated-version header is the most maintainable of these: it pins each client to a shape without creating a parallel routing tree to secure.
// Most changes do not need a version. Additive change is free; the discipline
// is knowing which is which.
// ── SAFE: no version needed ──────────────────────────────────────────
// Adding an OPTIONAL request field
{ name: 'x', description: 'y' } // description is new and optional
// Adding a response field — clients must ignore unknown fields, and you must
// document that they must.
{ id: 1, name: 'x', createdAt: '...' } // createdAt is new
// Adding a new endpoint, a new optional query parameter, a new enum value in a
// field clients only READ.
// ── BREAKING: needs a version, or a long migration ───────────────────
// Removing or renaming a field { name } → { fullName }
// Changing a type { total: 10.5 } → { total: "10.50" }
// Making an optional field required
// Changing the meaning of a value
// Adding an enum value clients must HANDLE (in a field they switch on)
// Changing pagination, error shapes, or default sort order
// ── Techniques that avoid a version ──────────────────────────────────
// 1. Add the new field, keep the old one, deprecate it in the docs.
res.json({
name: user.fullName, // deprecated, still present
fullName: user.fullName, // preferred
});
// 2. Opt-in behaviour by header, so nothing changes for existing clients.
const apiDate = req.get('X-API-Version-Date') ?? '2024-01-01';
if (apiDate >= '2026-01-01') {
return res.json(newShape(data));
}
return res.json(oldShape(data));
// Stripe's dated-version approach: each client is pinned to the shape that
// existed when they integrated, and upgrades are explicit.
// 3. Version the RESOURCE, not the whole API.
// /api/invoices ← unchanged
// /api/v2/payments ← only this one broke
// You then maintain one changed endpoint rather than a parallel copy of
// everything, which is the actual security cost of versioning.
// ── The rule that makes additive change possible ─────────────────────
// Document, prominently: "Clients MUST ignore unknown fields." Without that
// contract, every addition is breaking, and you end up versioning constantly.
Discussion