The authorizer I didn’t have to write (yet): guarding Scaffy’s admin routes with Cognito
The last post ended with every route wide open. The router walks a list of patterns and dispatches the first match, and in that code every one of those routes is public: anyone can read any collection. The writes under /admin obviously can’t work that way. So before I wrote a single admin handler, I had to answer one question: where does the guard go?
I assumed the answer was a Lambda authorizer, a little function of my own that inspects each request and votes yes or no. I didn’t end up writing one. API Gateway already ships the exact guard I needed, and wiring it in was a few lines rather than a new piece of code to own. Here’s why reaching for the built-in option was the simpler call, and the one case still coming that will make me write the custom one after all.
One Lambda, two doors
The whole backend still sits behind a single Lambda. What changes for auth is that it’s reachable through two doors. Public reads come in under /api, exactly the catch-all from last time. Admin writes get their own catch-all under /admin, pointed at the same Lambda integration.
// CDK stack (deploy time): public reads, no guard
api.addRoutes({
path: "/api/{proxy+}",
methods: [HttpMethod.ANY],
integration: contentIntegration,
});
// the auth adapter adds the guarded twin under /admin
authCdk.bindAuthorizer(this, api, contentIntegration); The /admin route isn’t in that first call. It’s registered inside bindAuthorizer, the auth adapter’s deploy-time half, which I open up in the next section. Same contentIntegration behind both. One Lambda, two doors, and the only difference between them is that one door has a bouncer. Everything the router and handlers do is identical once a request is inside; the /admin prefix is purely the hook the gateway uses to decide whether to check credentials first.
The guard API Gateway already ships
The bouncer lives in the auth adapter’s bindAuthorizer. A Cognito user pool is already doing the user management here, and HTTP APIs have a first-class authorizer for exactly that: HttpUserPoolAuthorizer. You hand it the pool and the client, attach it to a route, and you’re done.
bindAuthorizer(scope, api, integration) {
const { userPool, userPoolClient } = this.ensureUserPool(scope);
const authorizer = new HttpUserPoolAuthorizer("AdminAuthorizer", userPool, {
userPoolClients: [userPoolClient],
});
api.addRoutes({
path: "/admin/{proxy+}",
methods: [HttpMethod.ANY],
integration,
authorizer,
});
} That authorizer runs before the Lambda is ever invoked. On every /admin request it pulls the JWT off the Authorization header and checks it: real signature from this user pool, not expired, issued for this app client. A token that fails any of those gets a 401 straight from API Gateway. The Lambda doesn’t cold-start, the factory doesn’t build an adapter, the router never walks its list. And that’s the point: nothing inside the Lambda checks whether you’re allowed in. The handlers just do their work. The only thing standing between a bad request and a write is this authorizer at the door, so the request that shouldn’t get in is turned away before any of the code runs.
Whose job is the guard?
That bindAuthorizer method belongs to the auth adapter, not the stack. The stack never set the authorizer up itself; back in the two-doors snippet it just called authCdk.bindAuthorizer(...), handing the work to the auth adapter (authCdk). The code block just above is that adapter’s bindAuthorizer method running in response. So the guard is defined by the adapter and merely invoked by the stack. That’s the deploy-time / request-time split from the adapters post, applied to auth: auth is one concern with two halves, a cdk half and a runtime half, and the config names both in one place.
// scaffy.config.ts
auth: {
cdk: new CognitoCdkAdapter({ tokenTransport: "authorizationHeader" }),
runtime: (env) =>
new CognitoAdapter({
userPoolId: env.get("USER_POOL_ID"),
cognitoIdentityProviderClient: new CognitoIdentityProviderClient({}),
jwtVerifier: createCognitoJwtVerifier({
userPoolId: env.get("USER_POOL_ID"),
clientId: env.get("USER_POOL_CLIENT_ID"),
}),
}),
}, The cdk half creates the user pool at deploy time and hangs the authorizer off /admin. The runtime half builds the adapter the handlers actually call, reading the pool’s id and client id back out of env vars, the same handful of strings bridging the two lifecycles. Because the guard is part of the adapter, swapping auth swaps the gatekeeper with it. Point the config at a different auth adapter and /admin gets whatever that one puts in front of it; nothing in the stack changes.
Validated isn’t the same as known
The built-in authorizer answers one question: is this token valid? It doesn’t hand the code a user. The moment a handler wants to know who is making the request, the runtime auth adapter turns the token into one. That’s verifyToken, a method on the same Cognito adapter that owns the guard. It delegates the actual signature check to the injected verifier, then does the part the gateway never does: pull sub and email off the claims and return an AuthUser. Both the gate and the identity live in the one swappable adapter.
async verifyToken(token: string): Promise<Result<AuthUser>> {
const claims = await this.jwtVerifier.verify(token);
if (!claims.email)
return failure({
code: "CognitoAdapter.verifyToken",
errorMessage: "Token is missing the email claim",
});
return success({
id: claims.subject,
email: claims.email,
role: ROLES.ADMIN,
});
} That role: ROLES.ADMIN is hardcoded on purpose for now. Today the user pool exists only for admins, so every verified user is one. It’s stamped here rather than assumed elsewhere precisely so that real roles, when they land, have one place to change and the rest of the code already reads a role instead of taking admin for granted. Authentication answers who you are; what you’re allowed to do is a separate problem for another day.
With header transport, verifyToken re-checks a signature the gateway authorizer already validated before the Lambda ran, so that part of its work is duplicated. What isn’t duplicated is the AuthUser it builds from the claims, which the gateway never produces. The re-check only stops being wasted effort the instant the token stops arriving in a header.
That double-check is a deliberate choice, and worth explaining. A JWT authorizer also forwards the claims it already validated, so a handler could read those straight off the request context and skip re-verifying. But that shortcut only works for header transport, where the built-in authorizer runs. The cookie path uses a different authorizer, so those claims wouldn’t be there, and I’d end up with two separate ways to identify a user, one per transport. Re-verifying avoids that. verifyToken takes a raw token and returns a user, so both transports feed the same single routine. It’s cheap, too: the signing keys are cached and there’s no network hop.
The catch I’m shipping with
Now the part I haven’t built, and where the custom Lambda authorizer comes back. The adapter takes a tokenTransport option, and its default is httpOnlyCookie. That default isn’t built yet. Ask for it and the deploy fails on purpose.
if (this.tokenTransport !== "authorizationHeader")
throw new Error(
"CognitoCdkAdapter: the 'httpOnlyCookie' token transport is not implemented yet",
); The reason is the same trait that made the built-in authorizer so easy: it reads the token from exactly one place, an identity source whose whole value has to be the token, which by default is the Authorization header. That’s fine for a script or a CLI.
In a browser, plenty of developers would rather keep the token in an httpOnly cookie, one JavaScript can’t read, than in a header they wire up themselves. Whether a header or a cookie is the better home for a session token is a genuinely split debate, the two trade off differently against risks like XSS and CSRF, and the community is close to evenly divided on it. I don’t think either side is simply right, so I’d rather Scaffy offer both and let the call come down to preference or whatever a given setup needs.
The problem is the built-in authorizer can serve only one of them. And the catch isn’t the httpOnly flag, it’s cookies in general: the token would arrive inside the Cookie header as session=<jwt>; other=..., and the authorizer can’t reach into that and pull one named cookie out. It expects the source to be the bare token, so the header it wants is simply never set. For now Scaffy fails loud rather than pretending: ask for cookie transport and the deploy stops, and admin tools authenticate with a header until that path is built.
The shape of that path is already clear, which is why the title hedges. Where the header transport leans on the built-in authorizer, the cookie transport swaps in a custom Lambda authorizer on the same /admin route: a small function that reads the token out of the Cookie header, runs it through the same verifyToken the runtime adapter already exposes, and tells the gateway allow or deny. The route doesn’t change, only the guard in front of it does. The part I still have to settle is how that authorizer hands identity to the content Lambda, which is why the header transport came together first and the cookie default is still on the bench. So I didn’t have to write the authorizer yet. For my actual default, I will.