How does token-based APNs authentication work: what is in the JWT, how is it signed, and how often can it be refreshed?

17.8.1 · L5 · Interview

Predict first

What does the signed token look like, and what happens when the claims change?

import Foundation
import CryptoKit

func b64url(_ d: Data) -> String {
    d.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
}
func unb64url(_ s: String) -> Data {
    var t = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
    while t.count % 4 != 0 { t += "=" }
    return Data(base64Encoded: t)!
}
let key = P256.Signing.PrivateKey()          // stands in for the .p8 key downloaded from the developer account
func makeToken(kid: String, team: String, iat: Int) -> String {
    let h = b64url(Data(#"{"alg":"ES256","kid":"\#(kid)"}"#.utf8))
    let c = b64url(Data(#"{"iss":"\#(team)","iat":\#(iat)}"#.utf8))
    let sig = try! key.signature(for: Data((h + "." + c).utf8))
    return h + "." + c + "." + b64url(sig.rawRepresentation)
}
let jwt = makeToken(kid: "ABC123DEFG", team: "TEAM123456", iat: 1_700_000_000)
let parts = jwt.split(separator: ".").map(String.init)
print("segments:", parts.count)
print("header:", String(data: unb64url(parts[0]), encoding: .utf8)!)
print("claims:", String(data: unb64url(parts[1]), encoding: .utf8)!)
print("raw signature bytes:", unb64url(parts[2]).count)
let sig = try! P256.Signing.ECDSASignature(rawRepresentation: unb64url(parts[2]))
print("verifies:", key.publicKey.isValidSignature(sig, for: Data((parts[0] + "." + parts[1]).utf8)))
let forged = makeToken(kid: "ABC123DEFG", team: "TEAM123456", iat: 1_700_003_600).split(separator: ".").map(String.init)
print("claims swapped under old signature:", key.publicKey.isValidSignature(sig, for: Data((parts[0] + "." + forged[1]).utf8)))
print("padding left in token:", jwt.contains("="))
let token = Data([0xde, 0xad, 0xbe, 0xef, 0x00, 0x0f])
print("device token as hex:", token.map { String(format: "%02x", $0) }.joined())
print("description is not the token:", NSData(data: token).description)
  • Three segments, 64-byte raw signature, verifies, and fails after the claims are swapped
  • Four segments with a DER signature
  • The tampered claims still verify

Mental model. The .p8 key is a stamp, the JWT is a stamped note valid for an hour, and APNs only checks the stamp, never who is holding the note.

Instead of a per-app certificate, the provider holds one .p8 signing key (an elliptic-curve P-256 key) for the whole team. For each connection it builds a JWT: a header {"alg":"ES256","kid":""}, claims {"iss":"","iat":}, and a signature over header.claims, all base64url-encoded without padding, and sends it as authorization: bearer <jwt> next to the apns-topic header. The run shows the three segments, the claims, a 64-byte raw signature (the JWT form is the r and s values concatenated, not the DER encoding most libraries produce by default, a classic bug), that the signature verifies, and that changing the claims under the old signature does not. The device token is opaque bytes: send it to your server as a hex string, because description of the data is a debug format, not the token.

Apple documents that the token must be refreshed no more often than every 20 minutes and no less often than every 60 (a token older than an hour is rejected as ExpiredProviderToken, one regenerated too often as TooManyProviderTokenUpdates), so cache it and rotate on a timer rather than signing per request. Keep the .p8 file secret (it can push to every app of the team and is downloadable only once), use one key for sandbox and production, and note that the key id in kid is the key’s own id, not the team id. This run did not contact APNs: the 20 and 60 minute rules, the error names and the server’s responses come from Apple’s documentation, not from this check.

ES256 JWTs use a raw 64-byte signature; the signature covers the claims.

Common traps

  • Sending the DER signature. JWT ES256 wants the raw 64-byte r‖s form.
  • Signing a new token for every request. APNs throttles that; reuse one for 20 to 60 minutes.

They might follow up with

  • Certificate or token? Tokens never expire yearly, work for every app of the team, and carry no per-environment certificate.

Related questions

  • 17.1.2 How does the Apple Push Notification service deliver a push: tokens, priority, collapse, expiry, and what can…
  • 17.8.2 How do you budget an APNs payload: bytes versus characters, escaped slashes, and what belongs in it?

How do you budget an APNs payload: bytes versus characters, escaped… →