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.
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.
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.
Fix procedure
- Capture one failing request in full — endpoint,
Authorizationheader, response status and response body. The body often names the claim that failed. - Decode both JWT segments with the commands above and print
aud,expandsubas plain values. - Compare
expagainst 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. - Compare
audagainstnew URL(endpoint).origincharacter by character. A trailing slash is invisible in most log viewers. - Check
substarts withmailto:orhttps:and that the address actually reaches someone. - Confirm the keys came from the environment. Assert that
process.env.VAPID_PRIVATE_KEYandprocess.env.VAPID_PUBLIC_KEYare set and non-empty at boot; a container that lost its secrets mounts an empty string and signs with a zero key. - Verify the JWT is not shared across services. Log the cache key you use for signed tokens; it must include
aud. - 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
audwith a TTL comfortably shorter thanexp— a map keyed by origin, entries expiring after six hours, is a good shape. expis in seconds. PassingDate.now()instead ofDate.now() / 1000gives anexproughly fifty thousand years ahead, which reads as “beyond the ceiling” and returns401with a very confusing claim value.- A
401never means the subscription is dead. Do not prune the endpoint; that treatment belongs to404and410, as covered in handling 410 Gone responses at scale. - The legacy
WebPushscheme is not interchangeable. The draft form put the key in a separateCrypto-Key: p256ecdsa=…header; mixing it with the RFC 8292vapid 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.
Related
- VAPID Key Generation & Rotation — generating the P-256 pair and storing it safely in the environment.
- Rotating VAPID Keys Without Losing Subscribers — the
403sibling of this failure, caused by a key the subscription does not recognise. - VAPID vs APNs Authentication Differences — why one push service rejects a token the others accept.
- Core Protocols & Browser Implementation — where VAPID sits in the wider Web Push stack.
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.