Swift JWT Overview — Client vs Server
Swift runs in two very different worlds for JWT work. On iOS/macOS clients, you usually only need to decodethe token for UX purposes (show the user their email, check an expiry, read the user id). The actual authentication authority is your server — so verification doesn't need to happen on-device. On the server (Vapor, Hummingbird, Perfect), you're the authority and verification is mandatory. JWTKit is the de-facto library for Vapor, while Foundation + CryptoKit handles everything on iOS without pulling in dependencies.
Method 1: Foundation Decode (iOS, No Dependencies)
Minimal decode function
import Foundation
enum JWTError: Error { case invalidFormat, invalidBase64, invalidJSON }
func decodeJWTPayload(_ token: String) throws -> [String: Any] {
let parts = token.split(separator: ".")
guard parts.count == 3 else { throw JWTError.invalidFormat }
var payload = String(parts[1])
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
// Pad to multiple of 4
let padding = 4 - payload.count % 4
if padding != 4 { payload += String(repeating: "=", count: padding) }
guard let data = Data(base64Encoded: payload, options: [.ignoreUnknownCharacters]) else {
throw JWTError.invalidBase64
}
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw JWTError.invalidJSON
}
return json
}
// Usage
let claims = try decodeJWTPayload(myToken)
print(claims["sub"] as? String ?? "")
print(claims["exp"] as? TimeInterval ?? 0)Codable payload (type-safe, recommended)
struct JWTPayload: Decodable {
let sub: String
let email: String?
let exp: Date
let iat: Date
let role: String?
enum CodingKeys: String, CodingKey {
case sub, email, exp, iat, role
}
}
func decodeJWT<T: Decodable>(_ token: String, as type: T.Type) throws -> T {
let parts = token.split(separator: ".")
guard parts.count == 3 else { throw JWTError.invalidFormat }
var payload = String(parts[1])
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
let pad = 4 - payload.count % 4
if pad != 4 { payload += String(repeating: "=", count: pad) }
guard let data = Data(base64Encoded: payload) else { throw JWTError.invalidBase64 }
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .secondsSince1970 // JWT exp/iat are unix seconds
return try decoder.decode(T.self, from: data)
}
let payload = try decodeJWT(token, as: JWTPayload.self)
print(payload.sub, payload.exp) // "123", 2026-11-01 12:00:00 +0000Method 2: JWTKit (Vapor / Server-Side Swift)
Package.swift
.package(url: "https://github.com/vapor/jwt-kit.git", from: "4.0.0"),
// Target dependency
.product(name: "JWTKit", package: "jwt-kit"),Configure signers (configure.swift in Vapor)
import JWT
public func configure(_ app: Application) throws {
// HS256 shared secret
app.jwt.signers.use(.hs256(key: Environment.get("JWT_SECRET")!))
// Or RS256 public key for identity-provider tokens
if let pem = Environment.get("JWT_PUBLIC_KEY") {
let key = try RSAKey.public(pem: pem)
app.jwt.signers.use(.rs256(key: key))
}
// Or JWKS fetched from Auth0/Okta/Cognito
// (fetch via HTTP client, feed JSON to app.jwt.signers.use(jwks:))
}Payload struct + verify in route
struct AppPayload: JWTPayload {
let sub: SubjectClaim
let exp: ExpirationClaim
let role: String
func verify(using signer: JWTSigner) throws {
try exp.verifyNotExpired()
}
}
// Route
app.get("me") { req async throws -> Response in
let payload = try req.jwt.verify(as: AppPayload.self)
return Response(status: .ok, body: .init(string: payload.sub.value))
}Method 3: SwiftUI Example — Decode on Launch
import SwiftUI
@MainActor
class AuthViewModel: ObservableObject {
@Published var userEmail: String?
@Published var isExpired: Bool = false
func load(from keychain: KeychainStore) {
guard let token = keychain.read("access_token") else { return }
do {
let payload = try decodeJWT(token, as: JWTPayload.self)
self.userEmail = payload.email
self.isExpired = payload.exp < Date()
} catch {
print("Invalid stored token: \(error)")
}
}
}
struct ContentView: View {
@StateObject var auth = AuthViewModel()
var body: some View {
if let email = auth.userEmail, !auth.isExpired {
Text("Welcome \(email)")
} else {
LoginView()
}
}
}Method 4: Keychain Storage (Never Use UserDefaults)
import Security
struct KeychainStore {
func save(_ token: String, forKey key: String) {
let data = token.data(using: .utf8)!
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
]
SecItemDelete(query as CFDictionary)
SecItemAdd(query as CFDictionary, nil)
}
func read(_ key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var out: AnyObject?
guard SecItemCopyMatching(query as CFDictionary, &out) == errSecSuccess,
let data = out as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
}Method 5: On-Device Signature Verification (CryptoKit)
import CryptoKit
// HS256 verification
func verifyHS256(_ token: String, secret: String) throws -> Bool {
let parts = token.split(separator: ".")
guard parts.count == 3 else { return false }
let signingInput = "\(parts[0]).\(parts[1])".data(using: .utf8)!
let key = SymmetricKey(data: Data(secret.utf8))
let computed = HMAC<SHA256>.authenticationCode(for: signingInput, using: key)
// JWT signature is Base64URL
var sig = String(parts[2])
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
let pad = 4 - sig.count % 4
if pad != 4 { sig += String(repeating: "=", count: pad) }
guard let sigData = Data(base64Encoded: sig) else { return false }
return Data(computed) == sigData
}Common Swift JWT Pitfalls
- Forgetting Base64URL padding —
Data(base64Encoded:)is strict about=padding; you must pad manually to a multiple of 4. - Using UserDefaults for tokens — plaintext in the app sandbox, leaks via iTunes backup. Always Keychain.
- Decoding
expasInt— if the token comes from a server using floating-point seconds, Codable will fail. Decode asTimeIntervalorDatewith.secondsSince1970. - Verifying client-side with a shared secret — never ship HS256 secrets in an iOS binary. Attackers can extract strings from the IPA. Only verify server-side.
- Treating device clock as trusted — users can set their iPhone clock to any date. For high-security apps, fetch server time and compare instead of trusting
Date(). - Missing
try req.jwt.verifyin Vapor routes — JWTKit integrates viareq.jwt, not via middleware alone. You still need to call verify in each protected route.
Related Tools
- JWT Decoder Online — paste-and-inspect UI
- JWT Decoder in JavaScript — browser + Node.js
- JWT Decoder in Kotlin — Android counterpart
- Verify JWT Signature — HS256/RS256 deep dive
- Base64 Encode in Swift — the encoding JWT is built on