http-jwt

Verifies a JSON Web Token (JWT) on incoming HTTP requests. The verified payload is written to request.internal[payloadKey] (and optionally to request.context[payloadKey] when setToContext: true).

The token is resolved from the first available source in this order: cookie, header, query string. When no source is configured the middleware falls back to the Authorization: Bearer ... header.

Two key-source modes are supported:

  • issuers (recommended for OIDC). A map of issuer URL → { jwksUri, audience, algorithm? }. The middleware reads the token's iss claim, looks up the matching entry, fetches the public key from that issuer's JWKS (matched by kid), and verifies. Supports multiple issuers in one config. Key rotation, kid lookup, JWKS caching, and refresh-on-miss are handled internally via jose.createRemoteJWKSet.
  • internalKey. Reads a key from request.internal[internalKey], populated by another middleware that ran earlier. Used for KMS-hosted public keys (via @middy/kms), bare keys, or symmetric (HMAC) secrets.

This middleware does not check role / scope / permission claims. See Validating roles below for a small custom middleware you can drop in alongside it.

Install

npm install --save @middy/http-jwt
npm install --save jose

Options

  • issuers (object) (one of issuers/internalKey required): Map of issuer URL → { jwksUri, audience?, algorithm? }. See Issuers options for entry shape.
  • internalKey (string) (one of issuers/internalKey required): Key on request.internal holding the verification key. Accepts a { publicKey: Uint8Array, keySpec } shape from @middy/kms, a bare Uint8Array SPKI DER public key, an already-resolved KeyObject or CryptoKey, or a string symmetric secret. It may also hold an array of any of those; see Key rotation.
  • algorithm (string | string[]) (required for issuers; required for internalKey bare-key/HMAC shapes; auto-inferred for KMS shape): JWS algorithm allowlist. 'none' is rejected. Empty arrays are rejected.
  • tokenCookieName (string) (optional): Cookie name to read the token from.
  • tokenHeaderName (string) (optional): Custom header to read the token from. When the name is Authorization (case-insensitive), the Bearer scheme is stripped; any other scheme causes the source to fall through. Other header names return the raw value.
  • tokenQueryStringName (string) (optional): Query-string parameter to read the token from.
  • audience (string | string[]) (optional, ignored when issuers is used — per-entry audience is authoritative): Expected aud claim.
  • issuer (string | string[]) (optional, ignored when issuers is used): Expected iss claim.
  • clockTolerance (number) (default 0): Clock skew tolerance in seconds applied to exp/nbf checks.
  • expectedClaims (object) (optional): Claims the payload must carry, compared with strict equality, e.g. { token_use: 'access' }. A claim that is absent fails the same way a claim with the wrong value does. Checked after the signature and before the payload is published, so nothing downstream can read a payload this rejected. Distinct from jose's requiredClaims, which only asserts presence. Values must be a string, number, or boolean: an array or object could only match itself by reference, so it is refused at construction.
  • payloadKey (string) (default jwt): Key under which the decoded payload is stored.
  • setToContext (boolean) (default false): When true, the verified payload is also written to request.context[payloadKey]. By default it is written only to request.internal[payloadKey] (matches @middy/ssm and @middy/secrets-manager).
  • cacheExpiry (number) (optional, issuers only): JWKS cache TTL in ms. Forwarded to jose.createRemoteJWKSet's cacheMaxAge.
  • cooldownDuration (number) (optional, issuers only): Minimum interval in ms between JWKS refetches on kid miss. Forwarded to jose.createRemoteJWKSet's cooldownDuration.
  • disablePrefetch (boolean) (default false, issuers only): Skip the warm-up fetch fired at factory time for each issuer entry.

NOTES:

  • A missing or malformed token, an invalid signature, or a failed claim check throws 401 Unauthorized. Pair with http-error-handler to convert it into a proper HTTP response.
  • HMAC secrets (HS256/HS384/HS512) work via internalKey. There is no top-level secretKey option; see the HS256 example for the recommended shape. Asymmetric crypto (RS256/ES256) is strongly preferred for cross-service auth; HMAC is fine for webhook signatures and contained internal trust boundaries where you control both signer and verifier.

Sample usage

Verifying tokens from OIDC providers (Cognito, Auth0, Okta, etc.)

@middy/http-jwt ships first-class support for JWKS-based verification via the issuers option. Configure one entry per issuer URL; on each request the middleware reads the token's (unverified) iss claim, looks up the entry, fetches the matching public key from that issuer's JWKS by kid, and verifies. Key rotation, kid lookup, JWKS caching, and refresh-on-miss are handled internally.

import middy from '@middy/core'
import httpJwt from '@middy/http-jwt'
import httpErrorHandler from '@middy/http-error-handler'

const { COGNITO_REGION, COGNITO_USER_POOL_ID, COGNITO_CLIENT_ID } = process.env

const COGNITO_ISSUER = `https://cognito-idp.${COGNITO_REGION}.amazonaws.com/${COGNITO_USER_POOL_ID}`

const lambdaHandler = async (event) => {
  // The verified payload is on request.internal.jwt by default.
  // To use context.jwt as below, pass setToContext: true to httpJwt.
  return { statusCode: 200, body: JSON.stringify({ ok: true }) }
}

export const handler = middy()
  .use(
    httpJwt({
      issuers: {
        [COGNITO_ISSUER]: {
          jwksUri: `${COGNITO_ISSUER}/.well-known/jwks.json`,
          audience: COGNITO_CLIENT_ID,
        },
      },
      algorithm: 'RS256',
    }),
  )
  .use(httpErrorHandler())
  .handler(lambdaHandler)

Multi-pool / multi-issuer

The issuers map naturally supports more than one IdP. The middleware reads the token's iss claim, routes to the matching entry, and verifies with that entry's JWKS and audience. A token whose iss does not match any entry is rejected with 401 (cause.data: 'Unknown issuer'). A token claiming iss: A but signed by a key from another pool fails verification because pool A's JWKS does not contain the signing key.

const REGION = 'us-east-1'
const POOL_A = 'us-east-1_AAA'
const POOL_B = 'us-east-1_BBB'

httpJwt({
  issuers: {
    [`https://cognito-idp.${REGION}.amazonaws.com/${POOL_A}`]: {
      jwksUri: `https://cognito-idp.${REGION}.amazonaws.com/${POOL_A}/.well-known/jwks.json`,
      audience: 'pool-a-client-id',
    },
    [`https://cognito-idp.${REGION}.amazonaws.com/${POOL_B}`]: {
      jwksUri: `https://cognito-idp.${REGION}.amazonaws.com/${POOL_B}/.well-known/jwks.json`,
      audience: ['pool-b-client-id-1', 'pool-b-client-id-2'],
    },
  },
  algorithm: 'RS256',
})

Mixed algorithms

Each issuer entry can override the top-level algorithm. algorithm is string | string[], useful during IdP key-rotation windows where a JWKS temporarily contains both RS256 and ES256 keys.

httpJwt({
  issuers: {
    'https://cognito-idp.us-east-1.amazonaws.com/POOL': {
      jwksUri: '...',
      audience: 'client',
      // inherits algorithm: 'RS256'
    },
    'https://my-es256-idp.example.com': {
      jwksUri: 'https://my-es256-idp.example.com/.well-known/jwks.json',
      audience: 'client',
      algorithm: 'ES256', // per-issuer override
    },
    'https://rotating-idp.example.com': {
      jwksUri: 'https://rotating-idp.example.com/jwks.json',
      audience: 'client',
      algorithm: ['RS256', 'ES256'], // accept either during rotation
    },
  },
  algorithm: 'RS256',
})

Issuers options

Top-level (see Options for full details on each):

  • issuers (required), algorithm (required), cacheExpiry, cooldownDuration, disablePrefetch, clockTolerance, setToContext, payloadKey, token-source options.

Per entry:

  • jwksUri (string, required): JWKS document URL.
  • audience (string | string[], optional): Expected aud claim for tokens routed to this entry.
  • algorithm (string | string[], optional): Per-issuer override of the top-level allowlist. Replaces (does not merge with) the top-level for this entry.

With a KMS-hosted public key

Pair @middy/kms with @middy/http-jwt to verify tokens signed with an AWS KMS asymmetric key. The KMS middleware fetches the public key once per cold start and caches it; http-jwt reads it via internalKey and derives the algorithm from the key spec.

import middy from '@middy/core'
import kms from '@middy/kms'
import httpJwt from '@middy/http-jwt'
import httpErrorHandler from '@middy/http-error-handler'

const lambdaHandler = async (event) => {
  return { statusCode: 200, body: JSON.stringify({ ok: true }) }
}

export const handler = middy()
  .use(
    kms({
      fetchData: {
        jwtKey: 'alias/jwt-signing-key',
      },
    }),
  )
  .use(
    httpJwt({
      internalKey: 'jwtKey',
      // algorithm omitted: inferred from the KMS keySpec carried on internal.
      issuer: 'https://auth.example.com',
      audience: 'api.example.com',
    }),
  )
  .use(httpErrorHandler())
  .handler(lambdaHandler)

With an HMAC shared secret (HS256)

There is no top-level secretKey option. Symmetric secrets flow through the same internalKey contract as every other key shape: a small middleware that places the secret on request.internal before http-jwt runs.

This shape works well for webhook signature verification (e.g., a third-party webhook that signs payloads with a shared secret), or for a contained internal trust boundary where you control both signer and verifier. For cross-service auth, prefer JWKS (issuers mode) or KMS — see the security note below.

import middy from '@middy/core'
import httpJwt from '@middy/http-jwt'
import httpErrorHandler from '@middy/http-error-handler'

const provideHmacSecret = ({ internalKey = 'hmacSecret' } = {}) => ({
  before: (request) => {
    request.internal[internalKey] = process.env.JWT_SECRET
  },
})

const lambdaHandler = async (event) => {
  return { statusCode: 200, body: JSON.stringify({ ok: true }) }
}

export const handler = middy()
  .use(provideHmacSecret())
  .use(
    httpJwt({
      internalKey: 'hmacSecret',
      algorithm: 'HS256',          // required: pinned, security gate against alg substitution
      issuer: 'https://auth.example.com',
      audience: 'api.example.com',
      clockTolerance: 5,
    }),
  )
  .use(httpErrorHandler())
  .handler(lambdaHandler)

For multi-secret rotation, the before middleware can pick a secret based on a non-standard header or a custom claim (e.g., a kid you mint yourself) and place the chosen secret on request.internal. Keep the algorithm allowlist pinned in httpJwt either way.

Reading the token from a cookie

httpJwt({
  issuers: {
    [COGNITO_ISSUER]: { jwksUri: `${COGNITO_ISSUER}/.well-known/jwks.json`, audience: 'client' },
  },
  algorithm: 'RS256',
  tokenCookieName: 'session',
})

Resolving across cookie, header, and query string

When more than one source is configured the middleware tries each in order (cookie, header, query string) and uses the first match. Useful for APIs that accept tokens from a session cookie for browser clients and an Authorization: Bearer header for service clients.

httpJwt({
  issuers: {
    [COGNITO_ISSUER]: { jwksUri: `${COGNITO_ISSUER}/.well-known/jwks.json`, audience: 'client' },
  },
  algorithm: 'RS256',
  tokenCookieName: 'session',
  tokenHeaderName: 'Authorization',
  tokenQueryStringName: 'id_token',
})

Security note

The patterns above are safe against the two classic JWT verification mistakes:

  • alg substitution. Attackers can place alg: none or alg: HS256 in the protected header to try to bypass signature checks or use an RSA public key as an HMAC secret. The defenses are: (1) the algorithm allowlist is pinned by configuration, never read from the token; (2) in the JWKS path the lookup is also filtered by that allowlist, so a token claiming an unconfigured alg cannot find a key at all; (3) algorithm: 'none' is rejected at factory time.
  • Untrusted JWKS source. Each jwksUri is configured once at factory time. jose.createRemoteJWKSet ignores any jku claim in the token header, so an attacker cannot redirect key fetching to a server they control. The kid from the token header is a selector into a trusted JWKS, not a source of trust on its own — an unknown kid fails the lookup, and the attacker cannot forge a signature without the IdP's private key.

When to use which mode

  • JWKS (issuers): any OIDC/OAuth2-style cross-service auth where the IdP publishes a public keyset endpoint. Cognito, Auth0, Okta, Google, Azure AD, custom OIDC. Strongly recommended for production.
  • KMS via internalKey: tokens signed in-house by an AWS KMS asymmetric key. Good when you control both signer and verifier and want the signing key in KMS for audit/rotation.
  • HMAC via internalKey: webhook signatures (Stripe, GitHub, etc.), short-lived internal tokens inside a trust boundary you fully control. Avoid for cross-service auth — rotation is harder than asymmetric, and a leak from any verifier compromises every signer.

Other notes for Cognito users

  • Use audience: COGNITO_CLIENT_ID for ID tokens. Access tokens carry client_id instead of aud; either drop the audience check and validate payload.client_id in a follow-up middleware, or restrict the handler to one token type.
  • Cognito tokens also carry a token_use claim (id or access). To enforce which type your handler accepts, add a small middleware after http-jwt that reads request.internal.jwt.token_use and throws createError(401, ...) on mismatch.

Key rotation

The issuers path needs nothing here: a JWKS already rotates by kid, and this middleware refetches on a kid miss.

The internalKey path is the one that needs help. An asymmetric signing key cannot be rotated in place, so rotating a static key means standing up a second key and accepting both until the last token signed by the retiring one has expired. Point internalKey at an array to do that:

export const handler = middy()
  .before((request) => {
    // Two keys are genuinely current during the overlap. Order matters to
    // nothing: the signing key's own failure is reported wherever it sits.
    request.internal.signingKeys = [currentKey, retiringKey]
  })
  .use(httpJwt({ internalKey: 'signingKeys', algorithm: 'ES256' }))
  .use(httpErrorHandler())
  .handler(lambdaHandler)

Each entry resolves on its own, which matters for the @middy/kms shape: a keySpec narrows the algorithm allowlist for that key only, not for the middleware as a whole. So an RSA key and an EC key can sit in the same array with algorithm: ['RS256', 'ES256'], and each is checked against the algorithms it can actually carry.

A token signed by any configured key verifies. A token signed by none is a 401, the same as with a single key. An empty array is a 500: there is no key to try, so no rejection reason anyone could act on.

A resolved key is accepted alongside the raw bytes, for a key you already have in hand:

import { createPublicKey } from 'node:crypto'

const keys = process.env.JWT_PUBLIC_KEYS.split(/(?=-----BEGIN)/)
  .map((pem) => createPublicKey(pem))

export const handler = middy()
  .use({ before: (request) => { request.internal.signingKeys = keys } })
  .use(httpJwt({ internalKey: 'signingKeys', algorithm: 'ES256' }))
  .handler(lambdaHandler)

A CryptoKey works the same way, which is what jose's own importSPKI, importJWK and generateKeyPair hand back. Nothing here knows either one's provenance, so the configured algorithm is trusted exactly as it is for raw DER. jose refuses the pair if the key cannot carry that algorithm.

Requiring claims

A token that verifies is not automatically a token for this. Most issuers stamp a discriminator saying what kind of token it is. Amazon Cognito's token_use is the well-known one: an ID token and an access token are both signed by the same key set, and only that claim tells them apart. Accepting an ID token where an access token was meant is a real and common hole.

expectedClaims closes it declaratively:

httpJwt({
  issuers: {
    'https://cognito-idp.us-east-1.amazonaws.com/us-east-1_abc123': {
      jwksUri: 'https://cognito-idp.us-east-1.amazonaws.com/us-east-1_abc123/.well-known/jwks.json',
    },
  },
  algorithm: 'RS256',
  // An ID token from the same pool verifies perfectly. It is still not a token
  // for calling this API.
  expectedClaims: { token_use: 'access' },
})

Comparison is strict equality, and it applies on both the issuers and internalKey paths. A claim the payload does not carry at all fails the same way a wrong value does, which is what you want: a token minted before the discriminator existed must not slide through.

Do not confuse this with requireExp, which forwards jose's requiredClaims and only asserts that a claim is present. For anything beyond an exact match, such as a scope that must contain a value, write a small middleware; see Validating roles below.

Validating roles

@middy/http-jwt only verifies the signature and standard claims (iss, aud, exp, nbf). Role / scope / permission claims are application-specific and intentionally left to userland. The following inline middleware reads the decoded payload from request.internal (under payloadKey) and rejects the request when the required role is missing.

import middy from '@middy/core'
import httpJwt from '@middy/http-jwt'
import httpErrorHandler from '@middy/http-error-handler'
import { createError } from '@middy/util'

const requireRole = (requiredRole, { payloadKey = 'jwt', claim = 'roles' } = {}) => ({
  before: (request) => {
    const payload = request.internal[payloadKey]
    const roles = payload?.[claim]
    const has = Array.isArray(roles)
      ? roles.includes(requiredRole)
      : roles === requiredRole
    if (!has) {
      throw createError(403, 'Forbidden', {
        cause: { package: 'custom/require-role', data: `Missing role: ${requiredRole}` },
      })
    }
  },
})

const lambdaHandler = async (event) => {
  return { statusCode: 200, body: JSON.stringify({ ok: true }) }
}

export const handler = middy()
  .use(
    httpJwt({
      issuers: {
        [COGNITO_ISSUER]: { jwksUri: `${COGNITO_ISSUER}/.well-known/jwks.json`, audience: 'client' },
      },
      algorithm: 'RS256',
    }),
  )
  .use(requireRole('admin'))
  .use(httpErrorHandler())
  .handler(lambdaHandler)

Order matters: requireRole must run after httpJwt so the decoded payload is already on request.internal.

Bundling

jose is a peer dependency. To keep it out of your Lambda bundle, add jose to your bundler's exclude list and provide it via a Lambda Layer.

Pairs well with

See also

Last updated: