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.

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.

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.

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.