TL;DR
- Migrating an endpoint is easy. Migrating authentication isn't, because every consumer touches it on every call and none of them can move on your schedule. Plan for both models running concurrently for years.
- Absorb the variation at exactly one point. A resolver that returns the same principal shape regardless of which model authenticated the caller is what keeps the migration finishable.
- The moment identity leaks into handlers as
if (isV2), every handler is coupled to the migration and it can never end.- Make the auth model a property of the deployed surface — separate APIs, separate authorizers, separate base paths — not a runtime branch. Then decommissioning is deleting infrastructure, not hunting for conditionals.
- Don't key behaviour on the name of a deployment artefact. Branch on the thing you actually need, or on explicit configuration.
- Enumerate every ingress and name its authentication mechanism. The ones your gateway can't cover — third-party webhooks — are the most exposed and the most likely to have no answer at all.
Publishing a new version of an endpoint is a solved problem. Run both, deprecate one, delete it when the traffic hits zero. Consumers move at their own pace and nobody has to coordinate.
Authentication doesn't work like that. Every consumer touches it on every call, and you cannot change it unilaterally — every integrating team has to do work, in their own release cycle, against their own priorities, some of them at companies you don't control.
So the honest planning assumption is that both models run concurrently for a long time. Possibly years. The design question was never "how do we migrate?" It's "where does the switch live, and what does it touch?"
The one thing you have to get right
Absorb the variation at exactly one point, and give everything downstream a fixed contract.
export interface Principal {
tenantId: string;
accountName: string;
platform: string;
scopes: string[];
}
// The only place in the codebase that knows two auth models exist.
export async function resolvePrincipal(event: APIGatewayProxyEvent): Promise<Principal> {
if (event.requestContext.identity?.userArn) {
return fromSignedRequest(event); // platform-signed: identity on the request context
}
return fromBearerToken(event); // token-based: claims, or a lookup
}That's the whole design, and it's the part that determines whether the migration ever finishes.
Handlers call resolvePrincipal and get a Principal. They do not know which model served the caller. They don't branch on it, don't check for it, and — critically — don't need touching when a consumer migrates.
The failure mode this prevents is specific and I've watched it play out. Identity leaks into a handler as one innocuous if. Someone copies that handler as a template. Six months later there are forty of them, each with its own slightly different understanding of the two models, and now migrating a consumer means auditing forty files. At that point the migration is permanently unfinishable, and the two-model state becomes the architecture rather than a transition.
Contain it in one file and the blast radius of the entire migration is that file.
Make the model a property of the surface
The second decision: separate APIs with separate gateway-level authorizers, rather than one API with per-route auth.
LegacyApi:
Type: AWS::Serverless::Api
Properties:
StageName: !Sub "${Environment}-v2"
Auth:
DefaultAuthorizer: AWS_IAM
CurrentApi:
Type: AWS::Serverless::Api
Properties:
StageName: !Sub "${Environment}-v3"
Auth:
DefaultAuthorizer: TokenAuthorizer
Authorizers:
TokenAuthorizer:
FunctionArn: !Ref AuthorizerArnBoth mapped onto the same custom domain under different base paths, so a consumer's URL tells you how they authenticate.
Two properties follow, and both matter more than they sound:
The auth model is visible in infrastructure, not buried in a runtime branch. You can answer "which of our surfaces uses the old scheme?" by reading templates rather than by reading code.
Decommissioning is deleting a resource. When the last consumer moves, you delete an API and one branch in one resolver. Compare that with hunting conditionals across a codebase, which is the version most teams end up with and the reason most of these migrations never formally complete.
Don't branch on a name
Here's the mistake I'd most expect a team to make under deadline, because it's genuinely the cheapest thing to write.
// Don't do this.
if (event.requestContext.stage.includes('v2')) {
return fromSignedRequest(event);
}
return fromBearerToken(event);It works. Stage names are built from a template that includes the version, the two surfaces do differ by version, and the check is correct today.
It's also coupling your authentication behaviour to a naming convention on a deployment artefact, and the problems compound:
Nothing connects the two ends. The string in the template that builds the stage name and the string being searched for in the resolver live in different files, different languages, with no reference between them. No type system sees the relationship. No test asserts it.
Renaming a stage breaks authentication silently. Not a build error, not a deploy error — a request-time error where the wrong identity model is selected. In the worst case it doesn't error at all; it just resolves the wrong principal.
Substring matching is loose. includes('v2') matches anything containing those characters anywhere. Fine today, a trap the moment your naming scheme changes.
It's undiscoverable from the side that breaks it. Someone editing a stage name has no way to know a shared module is parsing it.
It conflates two different facts. The stage name encodes which surface this is. The resolver needs which credential the caller presented. Those correlate right now. They are not the same thing.
The fix in the example above is what I'd actually do: branch on the presence of the platform identity on the request context — the thing you actually need — rather than on a string that happens to correlate with it. Failing that, an explicit environment variable set next to where the stage is defined. Explicit, greppable, visible in the console, and it fails loudly when unset rather than quietly picking the wrong branch.
If you must key on a name because you're mid-migration and touching every template is unaffordable — a real constraint, and I've made that call myself — leave a comment at both ends. The thing about temporary switches keyed on naming conventions is that they outlive everyone who knew they were temporary.
The category that fits neither model
This is the part I'd most want a team to sit with.
Every system like this has ingress that can't use either scheme: inbound webhooks from third parties. A payment provider posting a notification cannot sign with your platform credentials or present your identity provider's token. Something has to accept an unauthenticated request.
Which means your trust boundary is not "the edge". It is "the edge, except the parts that face outward."
That inverts the intuition most people carry. The mental model of a gateway is a wall with a guard, everything inside trusted. But the endpoints receiving traffic you didn't initiate — the ones most exposed to the internet, most likely to be found by a scanner, most likely to be replayed — are exactly the ones the guard can't check.
The mechanism that fits this shape is a provider signature: an HMAC over the raw payload with a shared secret, or a signed token in a header. It doesn't require the caller to hold your credentials, only a secret you both know.
const TOLERANCE_SECONDS = 300;
function verifySignature(rawBody, headerSignature, headerTimestamp, secret) {
const age = Math.abs(Date.now() / 1000 - Number(headerTimestamp));
if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;
// Sign the timestamp alongside the body. Sign only the body and the
// timestamp is unauthenticated — a replayer just supplies a fresh one.
const signed = `${headerTimestamp}.${rawBody}`;
const expected = crypto.createHmac('sha256', secret).update(signed).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(headerSignature ?? '');
// Length check first — timingSafeEqual throws on mismatched lengths.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Three details that catch people.
Verify against the raw body, not the parsed-and-restringified one, because JSON round-tripping changes byte order and whitespace. On API Gateway that means reading event.body before anything touches it, and handling the case where the gateway base64-encoded it.
Use a constant-time comparison. === on a signature leaks information through timing — a small risk and a free fix.
And note what a signature does not prove. It establishes origin, not freshness and not uniqueness. A captured request, replayed verbatim, carries a perfectly valid signature. The timestamp above narrows the window to five minutes; it doesn't close it, and it's only available if the provider signs a timestamp — many do, some don't, and you can't add one unilaterally.
So inside that window, and entirely without it if the provider gives you nothing to bind to, the defence has to be duplicate suppression at the consumer: a stable key derived from the sender's own event identifier, claimed with a conditional write. That's a different mechanism with its own failure modes, and it's the subject of the next article.
The audit worth doing
Take an hour. List every point where a request enters your system: HTTP routes, queue consumers, topic subscribers, direct invocations, scheduled triggers.
For each one, name the authentication mechanism. Platform request signing. Gateway authorizer. Provider signature. Mutual TLS. Network isolation. Or "deliberately none, because X".
There are usually fewer than a dozen, and the list is short enough to keep in a README.
The value isn't the list. It's that an unauthenticated endpoint somebody chose is a position, and an unauthenticated endpoint nobody noticed is a finding — and those are indistinguishable until you write it down. Right now, in most systems, the only way to tell them apart is to read the templates and notice which ones lack an Auth block.
That's not a thing anyone does until an auditor asks.