What should code that reads a JWT check, why is a client usually not the one to trust it, and what is the “alg: none” trap?
Predict first
Which forged or stale tokens does the verifier accept?
import Foundation
import CryptoKit
extension Data {
var base64URL: String { base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "") }
init?(base64URL s: String) {
var t = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
while t.count % 4 != 0 { t += "=" }
self.init(base64Encoded: t)
}
}
func make(_ header: String, _ payload: String, _ key: SymmetricKey) -> String {
let h = Data(header.utf8).base64URL, p = Data(payload.utf8).base64URL
return "\(h).\(p)." + Data(HMAC<SHA256>.authenticationCode(for: Data("\(h).\(p)".utf8), using: key)).base64URL
}
struct Claims: Decodable { var sub: String; var exp: Double }
enum Rejection: Error { case malformed, algorithm, signature, expired }
func verify(_ token: String, _ key: SymmetricKey, now: Double) throws -> Claims {
let parts = token.split(separator: ".", omittingEmptySubsequences: false).map(String.init)
guard parts.count == 3, let hd = Data(base64URL: parts[0]), let pd = Data(base64URL: parts[1]), let sig = Data(base64URL: parts[2]) else { throw Rejection.malformed }
guard (try JSONSerialization.jsonObject(with: hd) as? [String: Any])?["alg"] as? String == "HS256" else { throw Rejection.algorithm }
guard HMAC<SHA256>.isValidAuthenticationCode(sig, authenticating: Data("\(parts[0]).\(parts[1])".utf8), using: key) else { throw Rejection.signature }
let c = try JSONDecoder().decode(Claims.self, from: pd)
guard c.exp > now else { throw Rejection.expired }
return c
}
let key = SymmetricKey(data: Data("server-secret".utf8))
let good = make(#"{"alg":"HS256"}"#, #"{"sub":"u1","exp":2000}"#, key)
func attempt(_ label: String, _ token: String, now: Double = 1000) {
do { print(label, "accepted for", try verify(token, key, now: now).sub) } catch { print(label, "rejected:", error) }
}
attempt("valid:", good)
attempt("expired:", good, now: 3000)
attempt("alg none:", Data(#"{"alg":"none"}"#.utf8).base64URL + "." + Data(#"{"sub":"admin","exp":2000}"#.utf8).base64URL + ".")
attempt("forged key:", make(#"{"alg":"HS256"}"#, #"{"sub":"admin","exp":2000}"#, SymmetricKey(data: Data("guess".utf8))))
var swapped = good.split(separator: ".").map(String.init)
swapped[1] = Data(#"{"sub":"admin","exp":2000}"#.utf8).base64URL
attempt("payload swapped:", swapped.joined(separator: "."))
attempt("garbage:", "abc")
print("ES256 signature bytes (raw r||s):", try P256.Signing.PrivateKey().signature(for: Data("x".utf8)).rawRepresentation.count)
- valid: accepted for u1 / expired: rejected: expired / alg none: rejected: algorithm / forged key: rejected: signature / payload swapped: rejected: signature / garbage: rejected: malformed / ES256 signature bytes (raw r||s): 64
- alg none: accepted for admin / forged key: accepted for admin
- expired: accepted for u1 / garbage: rejected: signature
Mental model. A JWT is a tamper-evident wristband with the guest’s name printed on it: anyone can read it, and the door staff must check the seal with their own stamp, not the stamp the wristband says to use.
A JWT is three base64url parts, header.payload.signature, where the signature covers the first two. Anyone can read the payload (it is only encoded, not encrypted), so never put secrets in it. A verifier must do the checks in order: split strictly into three parts; decide the algorithm itself rather than trusting the header’s alg (an attacker can write "alg":"none" with an empty signature, or switch an RS256 token to HS256 and sign with the public key as the HMAC secret); verify the signature over the exact received bytes with a constant-time comparison; then check the claims: exp and nbf with a small clock-skew allowance, iss and aud matching what you expect. The program implements this with HMAC-SHA-256 and shows each attack rejected for the right reason: a valid token is accepted, an expired one is rejected, alg: none is rejected at the algorithm check, a token signed with a different key and a token whose payload was swapped for sub: admin fail the signature check, and garbage fails the format check. ES256 signatures in a JWT are the raw 64-byte r||s form, not DER.
In an app, remember who verifies. The server must validate every token it receives; the client holds a token to present it, and normally cannot verify an HS256 token at all because that needs the server’s secret. A client may check exp to decide whether to refresh (treat it as a hint, since the device clock can be wrong) and may verify an asymmetric token (RS256 or ES256) against a public key it trusted, for instance the Sign in with Apple identity token, but a client-side check never replaces the server’s authorisation. Keep access tokens short-lived, store refresh tokens in the Keychain, and prefer opaque tokens if you do not need the client to read claims. Not run: RS256 verification and key rotation through a JWKS endpoint.
Only the genuine, unexpired token passes; each attack fails at a specific check.
Common traps
- Letting the token header choose the verification algorithm. Fix the expected algorithm on the verifier.
- Decoding the payload and trusting it without checking the signature. Base64 is not security.
- Using the device clock as authority for
exp. It is a hint; the server decides.
They might follow up with
- Why do apps still decode JWTs? To read display claims or the expiry for refresh scheduling, never to authorise.
- What do you verify besides the signature? Issuer, audience, expiry and not-before.
Related questions
- 14.6.5 How does PKCE protect an OAuth sign-in in an app, how is the code challenge computed, and what are state and…
- 14.6.3 How do P-256 and Ed25519 signatures differ in CryptoKit: sizes, encodings, determinism, and interoperating…
- 17.5.1 How do passkeys, Sign in with Apple and ASWebAuthenticationSession work in AuthenticationServices?
- 12.9.4 How does HMAC request signing work, what must a canonical string include, and how do you verify a signature…
← How does PKCE protect an OAuth sign-in in an app, how is the code…