VAPID vs APNs Authentication Differences: Debugging Cross-Platform Push Failures

When migrating native iOS push infrastructure to web standards, the most common failure modes are 401 Unauthorized and 403 Forbidden errors caused by protocol mismatches — not network problems. The two authentication systems use different cryptographic curves, different JWT claim schemas, different endpoint routing models, and different subscription lifecycle behaviors. Conflating them produces errors that look identical in aggregated logs but require completely different fixes.

Quick-Answer Comparison

Dimension VAPID (Web Push) APNs (Native iOS/macOS)
Standard RFC 8292 Apple Developer docs
JWT algorithm ES256 (ECDSA P-256) ES256 (ECDSA P-256)
Key source Self-generated key pair Apple Developer Portal
Auth header Authorization: WebPush <jwt> Authorization: Bearer <jwt>
Token lifetime ≤ 24 hours ≤ 60 minutes
Endpoint target Push service URL per subscription api.push.apple.com/3/device/<token>
Subscription binding JWT public key embedded at subscribe time Device token; no JWT binding
410 Gone Subscription expired/revoked Device deregistered ("reason": "Unregistered")
Payload limit 4 KB encrypted (aes128gcm) 4 KB (APNs v3)

Both systems use ES256, but the key origin, JWT claim schema, and routing model differ enough that mixing them is not trivially possible — a VAPID JWT sent to an APNs endpoint is rejected immediately, and vice versa.

VAPID and APNs authorization paths side by side In the VAPID lane your app server signs an ES256 JWT with a self-generated private key and posts it with a WebPush authorization prefix to the subscription endpoint, which verifies it against the key embedded at subscribe time. In the APNs lane the same algorithm is used but the key is Apple-issued, the prefix is Bearer, and the request is routed to a device token under api.push.apple.com. VAPID — subscription-bound, key you generate Your app server signs an ES256 JWT with VAPID_PRIVATE_KEY from env Authorization: WebPush aud = push service origin Subscription endpoint verifies against the key bound at subscribe() time Both ES256 — different key origin, header prefix and routing model APNs — device-token, key Apple issues Your app server signs an ES256 JWT with the Apple-issued .p8 key Authorization: Bearer iss = Team ID, kid = Key ID Device endpoint /3/device/<token> with apns-topic = bundle id Cross-posting either token to the other service is an instant 401 — the paths never converge.
The two authorization paths in parallel: identical signing algorithm, entirely separate key origin, header prefix and routing target.

Cryptographic Handshake Deep-Dive

VAPID (RFC 8292)

VAPID authenticates the application server to the push service using an ECDSA P-256 private key that you generate and control. The private key signs a compact JWT attached to every push POST request as Authorization: WebPush <token>. The corresponding public key is embedded in the push subscription at subscription time: the browser passes it as the applicationServerKey argument to pushManager.subscribe(), and the push service retains it to verify subsequent VAPID JWTs.

This means the VAPID public key is a subscription-time binding. Changing the public key invalidates all existing subscriptions — they were created with a different key fingerprint and the push service will reject JWTs signed with the new private key for those subscriptions. Refer to VAPID Key Generation & Rotation for the exact zero-downtime rotation strategy that avoids this.

VAPID JWTs must carry three claims:

  • aud — the push service origin (e.g., https://fcm.googleapis.com for Chrome subscriptions via FCM)
  • sub — a mailto: or verified https: contact URI per RFC 8292
  • exp — Unix timestamp, strictly capped at 24 hours from iat
POST /fcm/send/<subscription-endpoint-path> HTTP/2
Host: fcm.googleapis.com
Authorization: WebPush eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9...
TTL: 86400
Urgency: high
Content-Type: application/octet-stream
Content-Encoding: aes128gcm

VAPID private keys must never be hardcoded in server code. Load them from process.env.VAPID_PUBLIC_KEY and process.env.VAPID_PRIVATE_KEY. See Core Protocols & Browser Implementation for the broader security context.

APNs (Apple Push Notification service)

APNs supports two authentication modes. Token-based auth (the current standard) uses a JWT signed with an ES256 private key downloaded from the Apple Developer Portal. The key is associated with a specific Key ID (kid) and Team ID (iss). The JWT must be refreshed every 60 minutes — a far shorter window than VAPID’s 24-hour maximum.

APNs routing is device-centric rather than subscription-centric. Each request targets https://api.push.apple.com/3/device/<device-token> with the bundle ID in an apns-topic header. There is no public key embedded in the device token — the token is an opaque identifier allocated by APNs.

POST /3/device/<device-token> HTTP/2
Host: api.push.apple.com
Authorization: Bearer <apns-jwt>
apns-topic: com.example.app
apns-push-type: alert
apns-expiration: 0
apns-priority: 10
Content-Type: application/json

Certificate-based APNs auth (TLS client certificate presented at connection time) is deprecated. Migrate to token-based auth before Apple withdraws certificate support.

The practical consequence of the two lifetimes is a different caching strategy on each side. A VAPID JWT signed once can serve a full day of dispatch, so most senders cache it per push-service origin and re-sign on a timer well inside the cap; an APNs token has to be refreshed roughly every 50 minutes or a high-throughput pipeline will hit 403 ExpiredProviderToken mid-batch. Both failures look like generic auth errors in aggregated logs, which is why VAPID JWT expired 401 Unauthorized errors is worth reading alongside this page before you tune either refresh interval.

VAPID and APNs token lifetime windows The VAPID track spans 24 hours with a recommended re-sign at 12 hours and a hard expiry cap at 24 hours. The APNs track spans 60 minutes with a proactive refresh at 50 minutes and expiry at 60 minutes, after which requests fail with an expired provider token error. Signing cadence: one token lasts a day, the other under an hour VAPID JWT exp ≤ 24 h re-sign at 12 h hard cap 24 h 0 h 12 h 24 h one cached signature can authorize a full day of sends to the same push service origin APNs JWT iat < 60 min refresh at 50 min expiry 60 min 0 min 50 min 60 min refresh proactively — waiting for 403 ExpiredProviderToken leaves a delivery gap mid-batch Never let the two refresh timers share a scheduler — a stalled job then breaks both channels at once.
Token lifetime windows to scale: the VAPID cap is 24 hours, the APNs window is 60 minutes, and each needs its own proactive refresh point.

Diagnostic Workflow: Isolating 401 and 403 Failures

Work through these steps in sequence. The cause of a 401 or 403 is almost always one of three things: wrong header format, invalid JWT claim, or a stale subscription/device token.

Step 1 — Confirm the Correct Authorization Header Format

The header prefix differs. VAPID uses Authorization: WebPush <jwt>; APNs uses Authorization: Bearer <jwt>. Sending Bearer to a VAPID endpoint, or WebPush to APNs, produces an immediate 401 with no further diagnostic detail.

// VAPID: the web-push library sets the correct header automatically
const webpush = require('web-push');
webpush.setVapidDetails(
  'mailto:ops@example.com',
  process.env.VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY
);
await webpush.sendNotification(subscription, JSON.stringify(payload), { TTL: 86400 });

// APNs (token-based): set Authorization: Bearer <jwt> + required routing headers
// Use an APNs client library (node-apn, apn-http2) that handles JWT refresh.

Step 2 — Decode and Validate JWT Claims

Paste both JWTs into a standard JWT debugger (jwt.io or similar — use private/incognito mode for production tokens). Verify:

VAPID JWT required claims:

  • aud exactly matches the push service origin for the subscription. Chrome subscriptions route through FCM (https://fcm.googleapis.com); Firefox subscriptions through Mozilla autopush (https://updates.push.services.mozilla.com). Wrong aud produces 401.
  • sub is a valid mailto: or https: URI.
  • expiat + 86400 (24 hours). Expired JWTs produce 401.
  • Algorithm header is ES256. Other algorithms produce 401.

APNs JWT required claims:

  • iss is the 10-character Apple Developer Team ID.
  • iat is within the last 60 minutes. Tokens older than 60 minutes produce 403 ExpiredProviderToken.
  • kid header matches the Key ID shown in the Apple Developer Portal for this key.
  • Algorithm header is ES256.

Side by side, the two claim sets overlap on exactly one field. Everything else is protocol-specific, so a validator written for one schema will pass tokens the other service rejects.

VAPID versus APNs JWT claim sets The VAPID card lists alg ES256, an aud claim naming the push service origin, a sub contact URI and an exp claim. The APNs card lists alg ES256, an iss Team ID, an iat timestamp under sixty minutes old and a kid header. Only the algorithm field is shared. One shared field, four protocol-specific ones on each side VAPID JWT claims alg: ES256 aud: https://fcm.googleapis.com sub: mailto:ops@example.com exp: iat + 43200 self-generated key, scoped to the subscription APNs JWT claims alg: ES256 iss: 10-character Team ID iat: now, under 60 min old kid: Key ID (header) Apple-issued key, scoped to the Team ID identical Same curve, different key origin — never reuse a VAPID pair as an APNs key or vice versa.
Claim sets compared: only alg: ES256 is shared, which is why one protocol's validator quietly accepts the other's invalid tokens.

Step 3 — Verify Subscription / Device Token Currency

// VAPID: check subscription validity before dispatching
const sub = await reg.pushManager.getSubscription();
if (!sub) {
  // Re-subscribe the user — subscription expired or was cleared by the browser
}

// Cross-reference against your backend store
const storedEndpoint = await db.getEndpointForUser(userId);
if (storedEndpoint !== sub.endpoint) {
  // Update backend — endpoint changed (e.g., after a pushsubscriptionchange event)
  await db.updateEndpoint(userId, sub.endpoint);
}

Web push subscriptions expire silently when users clear site data, switch browser profiles, or revoke permissions. APNs device tokens persist until the app is uninstalled or explicitly deregistered. A 410 Gone from either system means the endpoint is permanently invalid — purge it immediately and do not retry. The handling 410 Gone responses at scale guide covers automated purge workflows for both protocols.

Step 4 — Validate the Payload Content-Encoding

VAPID payloads must use Content-Encoding: aes128gcm (RFC 8291). The push service enforces this header. The payload size limit is 4 KB after encryption; plaintext should be capped at 3,800 bytes to absorb aes128gcm framing overhead (minimum 38 bytes of salt, record-size, auth-tag, and padding). See Maximum Payload Size Limits for Chrome vs Firefox for the full breakdown.

APNs payloads are JSON and carry their own 4 KB size limit, but there is no transport-layer content-encoding requirement equivalent to aes128gcm — APNs uses TLS at the connection level for confidentiality.

Error Code Matrix

Error VAPID cause APNs equivalent Resolution
401 Unauthorized Invalid/expired JWT, wrong aud, wrong key ExpiredProviderToken (JWT > 60 min) Re-sign JWT; verify aud matches push service origin
403 Forbidden Wrong public key for subscription, revoked endpoint InvalidProviderToken (key revoked) Check key-subscription binding; verify Key ID and Team ID
404 Not Found Endpoint path typo or subscription never registered Device token not found for bundle Re-subscribe / re-register device
410 Gone Subscription explicitly unsubscribed or expired Unregistered device token Purge endpoint immediately; do not retry
413 Payload Too Large Plaintext exceeded 3,800-byte safe cap after aes128gcm encoding Payload > 4 KB Minimize payload; use notification-ID-plus-fetch pattern
429 Too Many Requests Burst rate limit exceeded TooManyRequests Implement exponential backoff; see retry logic guide

Gotchas & Edge Cases

  • VAPID aud must be the push service origin, not the subscription endpoint URL. A subscription endpoint for Chrome looks like https://fcm.googleapis.com/fcm/send/eAH.... The aud claim must be only https://fcm.googleapis.com, not the full path. Using the full URL produces a 401 with no explanation from FCM.
  • APNs JWT tokens must be refreshed proactively, not reactively. Generating a new token only after a 403 ExpiredProviderToken creates a delivery gap for high-throughput pipelines. Cache tokens and refresh 5–10 minutes before the 60-minute boundary.
  • VAPID key rotation and APNs key revocation have different blast radii. Rotating a VAPID public key invalidates all web subscriptions; a revoked APNs key blocks all native push until a new key is provisioned and the binary is re-signed (or the provider token updated). Coordinate rotation schedules separately — they must not coincide. Refer to Rotating VAPID Keys Without Losing Subscribers for the VAPID-specific strategy.
  • APNs device tokens change after OS upgrades and re-installs. Your backend must handle device token renewal events from the APNs Feedback Service or the APNs v3 HTTP/2 response headers (apns-id on success; reason in JSON body on failure). Stale tokens produce 410 Unregistered.
  • Both protocols use ES256 but with different key sources. Do not attempt to use a VAPID-generated key pair for APNs or vice versa. APNs keys are provisioned by Apple and scoped to a Team ID; VAPID keys are self-generated and scoped to a subscription endpoint. They are operationally separate even though the algorithm is identical.

Back to VAPID Key Generation & Rotation

FAQ

Why does VAPID use a 24-hour token lifetime but APNs only allows 60 minutes?

RFC 8292 sets VAPID JWT exp at a maximum of 24 hours from iat to reduce signing overhead on high-throughput servers while still limiting the window of a compromised token. APNs enforces a stricter 60-minute limit because APNs tokens are Apple-issued credentials tied to a provisioning profile; the shorter window limits the exposure of a leaked token and aligns with Apple’s server-side key revocation model. In practice, cache your VAPID JWT and re-sign every 12 hours; cache your APNs JWT and re-sign every 50 minutes to avoid delivery gaps.

Can I use the same push infrastructure for both web push (VAPID) and APNs?

At the authentication and endpoint level, no — the headers, JWT schemas, and endpoint routing are completely different. You need separate dispatch paths: a VAPID-signed HTTP POST to the subscription endpoint for web push, and an APNs HTTP/2 POST to api.push.apple.com for native iOS. However, you can share the same notification content layer (the JSON payload object defining title, body, and data), and you can use a unified worker queue that routes to the correct sender based on the subscription type stored in your database.

What is the correct aud claim value for VAPID JWTs targeting Chrome vs Firefox?

The aud claim must be the origin of the push service that receives the POST request — not the full subscription endpoint URL. For Chrome subscriptions routed through FCM, the endpoint starts with https://fcm.googleapis.com/... so aud must be https://fcm.googleapis.com. For Firefox subscriptions through Mozilla autopush, endpoints start with https://updates.push.services.mozilla.com/... so aud must be https://updates.push.services.mozilla.com. The web-push library extracts and sets the correct aud automatically from the subscription endpoint — only set it manually if you are constructing JWTs without the library.