VAPID JWT Expired: Fixing 401 Unauthorized Errors

A 401 Unauthorized from the push service means it read your Authorization header and rejected the VAPID JWT inside it — almost always because of exp, and almost always on every send from one host rather than on one unlucky message.

Quick answer

Sign a fresh JWT per request (or per short-lived batch) with exp set to twelve hours from now, aud set to the scheme and host of the subscription endpoint with no path, and sub set to a mailto: or https: URI. Load the signing key from process.env.VAPID_PRIVATE_KEY and the matching public key from process.env.VAPID_PUBLIC_KEY. Ranked by how often each one is the culprit:

# Root cause Signal Fix
1 exp more than 24 h ahead 401 on every send, immediately after deploy clamp to now + 12h
2 exp already in the past 401 from one host only, or after an uptime spike sync the clock, sign at send time
3 aud carries a path or trailing slash 401 from one push service, others fine scheme + host only
4 sub is not mailto: or https: 401 from Mozilla autopush first use a real contact URI
5 Key or signature encoding wrong 401 from every service, from day one base64url raw keys, JOSE ES256
6 One JWT reused across authorities 401 from all but one service cache per aud, not globally

A 403 is a different failure: it means the VAPID public key you signed with is not the applicationServerKey the subscription was created with, which is a key rotation problem rather than a JWT problem.

What the push service actually validates

RFC 8292 defines the vapid authentication scheme. The header carries two parameters: t, the signed JWT, and k, the base64url-encoded uncompressed P-256 public key.

POST /wp/fFq3rQ8bK2M HTTP/1.1
Host: updates.push.services.mozilla.com
Authorization: vapid t=eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJhdWQiOiJodHRwczov…, k=BJ7v2Sq…
Content-Encoding: aes128gcm
TTL: 86400

The service base64url-decodes k, verifies the JWT’s ES256 signature against it, and then checks three claims: that aud names itself, that exp is in the future, and that exp is not more than 24 hours ahead. Any of those failing produces 401. Note the ordering — a signature that does not verify fails before the claims are even read, which is why an encoding bug looks identical to an expiry bug from the outside.

Anatomy of the VAPID JWT The token splits into a header declaring ES256, a payload carrying aud, exp and sub, and a signature that must be sixty-four raw bytes rather than a DER structure. Three segments, three ways to earn a 401 header . payload . signature alg ES256 typ JWT RS256 is rejected P-256 curve only aud https://fcm.googleapis.com exp now + 43200 seconds sub mailto:ops@example.com No path in aud, ever. exp is seconds, not milliseconds. 64 raw bytes r concatenated with s DER wrapping fails signed from the env var never a hardcoded key Signature verification happens before claim validation. An encoding bug and an expiry bug produce the identical 401 — decode the token to tell them apart.
The three segments and the constraint each one carries.

Cause 1 and 2: everything about exp

RFC 8292 says exp must not be more than 24 hours after the time of the request. That is a ceiling, not a target. Teams reach for 24 hours because it minimises signing work, then discover that any drift, any queue delay, any retry after a backoff window pushes a token that was signed at the ceiling past it by the time the service evaluates it. Twelve hours removes the whole class of problem at no measurable cost — ES256 signing is microseconds.

The second failure is the mirror image. If the sending host’s clock is behind real time, the exp you compute from that clock is already in the past as far as the push service is concerned, and every request from that host returns 401 while identical code on a correctly synchronised host succeeds. This shows up as a partial outage that tracks a single machine or a single container image, which is why it is so often misdiagnosed as a load-balancer problem. Two symptoms identify it: the failures are host-correlated, and they persist across a restart but vanish after an NTP sync.

The window in which exp is accepted Before the request time the token is already expired, between the request time and twenty-four hours later it is valid with twelve hours as the recommended value, and beyond twenty-four hours the push service refuses it. exp has a floor and a ceiling, and both return 401 12 hours is the sane default already past accepted window any exp in here verifies over ceiling request time as the push service sees it request time + 24 h RFC 8292 hard ceiling A slow clock moves your whole window left, past the service's floor. Signing at 24 h leaves no margin for queue delay, retries or a few seconds of drift. Sign at send time, not at enqueue time, so a message held in a backoff queue never carries a stale token.
Both edges of the window produce the same status code.

Signing at enqueue time is the third variant of the same bug. A message that sits in a retry queue for eighteen hours arrives at the push service with a token signed eighteen hours ago; at a twelve-hour exp it is dead, and at a twenty-four-hour exp it has six hours left and will fail the next time the backoff doubles. Generate the Authorization header in the HTTP client, immediately before the request, and never persist it alongside the queued message.

Cause 3, 4 and 5: the claims and the keys

aud is an origin, not a URL. Take the subscription endpoint, parse it, and use only the scheme and host. https://fcm.googleapis.com/fcm/send/dEf… yields https://fcm.googleapis.com — no path, no trailing slash, no port unless the endpoint carries a non-default one. A trailing slash is the most common form of this error because new URL(endpoint).origin is correct while string manipulation with split('/') frequently is not.

sub must be a mailto: or https: URI. It exists so the push service operator can reach you when your traffic misbehaves. A bare email address, a domain with no scheme, or a placeholder like admin is a malformed claim. Mozilla autopush enforces this strictly; other services have been known to tolerate it, which is exactly how the bug survives into production and then appears the day you add a second push service.

Keys must be raw and base64url. The VAPID private key is the 32-byte P-256 private scalar, base64url-encoded without padding; the public key is the 65-byte uncompressed point beginning with 0x04, likewise base64url. Two encoding mistakes dominate: pasting a PEM block where a raw key is expected, and using standard base64 (with +, / and =) where base64url is required. The second is insidious because roughly three quarters of keys contain neither + nor /, so the bug appears random across environments and disappears when you regenerate the key.

The signature must be JOSE-flat. ES256 requires the raw concatenation of r and s, 32 bytes each. Most crypto libraries produce a DER-encoded SEQUENCE by default; handing that to the JWT assembler yields a token that no push service can verify. If you use an established web push library this is handled, and it is one of the strongest arguments for not assembling the header by hand.

Decoding and inspecting the JWT

Before changing any code, read the token you actually sent. The header is not encrypted — it is base64url, and decoding it takes one command.

# Extract the t= parameter from a captured Authorization header, then decode both segments.
JWT='eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJhdWQiOiJodHRwczovL2ZjbS5nb29nbGVhcGlzLmNvbSIsImV4cCI6MTc4NTAwMDAwMCwic3ViIjoibWFpbHRvOm9wc0BleGFtcGxlLmNvbSJ9.sig'
echo "$JWT" | cut -d. -f1 | base64 -d 2>/dev/null; echo
echo "$JWT" | cut -d. -f2 | base64 -d 2>/dev/null; echo
date -u -d @1785000000    # compare exp against real UTC, not your server's idea of it

In a Node service, assert the claims at send time rather than reading them after a failure.

const webpush = require('web-push');

webpush.setVapidDetails(
  'mailto:ops@example.com',
  process.env.VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY
);

const TWELVE_HOURS = 12 * 60 * 60;

function inspectVapidHeader(endpoint) {
  const audience = new URL(endpoint).origin;     // scheme + host, nothing else
  const headers = webpush.getVapidHeaders(
    audience,
    'mailto:ops@example.com',
    process.env.VAPID_PUBLIC_KEY,
    process.env.VAPID_PRIVATE_KEY,
    'aes128gcm',
    Math.floor(Date.now() / 1000) + TWELVE_HOURS
  );

  const token = headers.Authorization.match(/t=([^,]+)/)[1];
  const claims = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString());
  const skew = claims.exp - Math.floor(Date.now() / 1000);

  if (claims.aud !== audience) throw new Error('aud mismatch: ' + claims.aud);
  if (skew <= 0) throw new Error('exp already past — check the system clock');
  if (skew > 86400) throw new Error('exp beyond the 24 hour ceiling');
  if (!/^(mailto:|https:)/.test(claims.sub)) throw new Error('sub scheme invalid');

  return { claims, secondsUntilExpiry: skew };
}

Run that assertion in a startup health check on every host. A machine with a bad clock then fails to start instead of quietly returning 401 for its share of your traffic.

Working a 401 in order of likelihood Five checks from most to least likely — exp beyond the ceiling, exp in the past, a path in aud, a bad sub scheme, and a DER signature — each with the corresponding fix. Work the causes in this order — the first two cover most incidents 1. exp more than 24 hours ahead? Clamp to now + 43200 seconds 2. exp already past on this host? Sync NTP; sign at send time 3. aud carries a path or slash? Use new URL(endpoint).origin 4. sub missing mailto: or https:? Set a reachable contact URI 5. signature DER instead of raw? Emit 64 raw bytes, r then s A 403 is not on this list: it means the public key does not match the applicationServerKey of the subscription.
Five checks, ordered by how often each one is the answer.

Fix procedure

  1. Capture one failing request in full — endpoint, Authorization header, response status and response body. The body often names the claim that failed.
  2. Decode both JWT segments with the commands above and print aud, exp and sub as plain values.
  3. Compare exp against real UTC, not the sending host’s clock. Use an external time source. If the difference is negative, or greater than 86,400, you have found it.
  4. Compare aud against new URL(endpoint).origin character by character. A trailing slash is invisible in most log viewers.
  5. Check sub starts with mailto: or https: and that the address actually reaches someone.
  6. Confirm the keys came from the environment. Assert that process.env.VAPID_PRIVATE_KEY and process.env.VAPID_PUBLIC_KEY are set and non-empty at boot; a container that lost its secrets mounts an empty string and signs with a zero key.
  7. Verify the JWT is not shared across services. Log the cache key you use for signed tokens; it must include aud.
  8. Re-send the failed message with a freshly signed token before changing anything else. If it succeeds, the token was the whole problem and you can stop.

Gotchas and edge cases

  • Caching one JWT for all endpoints is wrong. Each push service is a separate audience. Cache per aud with a TTL comfortably shorter than exp — a map keyed by origin, entries expiring after six hours, is a good shape.
  • exp is in seconds. Passing Date.now() instead of Date.now() / 1000 gives an exp roughly fifty thousand years ahead, which reads as “beyond the ceiling” and returns 401 with a very confusing claim value.
  • A 401 never means the subscription is dead. Do not prune the endpoint; that treatment belongs to 404 and 410, as covered in handling 410 Gone responses at scale.
  • The legacy WebPush scheme is not interchangeable. The draft form put the key in a separate Crypto-Key: p256ecdsa=… header; mixing it with the RFC 8292 vapid t=…, k=… form produces a header no service parses.
  • Apple’s gateway is stricter than the rest. Safari’s push service rejects some tokens that FCM tolerates, so a Safari-only wave of 401s usually points at a claim the other services were quietly forgiving — the differences are compared in VAPID vs APNs authentication.

Back to VAPID Key Generation & Rotation

FAQ

How long should a VAPID JWT be valid for?

Twelve hours. RFC 8292 caps the exp claim at 24 hours after the request, and signing at that ceiling leaves no margin for clock drift, queue delay or a retry after backoff — any of which pushes the token past the limit by the time the push service reads it. Twelve hours halves the window at no meaningful cost, because ES256 signing takes microseconds.

What exactly goes in the aud claim?

The scheme and host of the subscription endpoint and nothing else. Parse the endpoint and take its origin, so an FCM endpoint yields https://fcm.googleapis.com with no path and no trailing slash. Building the value with string splitting rather than a URL parser is the usual source of a stray slash, which is enough on its own to earn a 401.

Is a 401 the same as a 403 from a push service?

No. A 401 means the Authorization header itself was rejected — a bad signature or an invalid claim such as exp or aud. A 403 means the header verified but the VAPID public key does not match the applicationServerKey the subscription was created with, which is a key rotation problem. Never prune a subscription on either code; only 404 and 410 indicate a dead endpoint.