```html SwiftUI: How to Implement JWT Handling (iOS 17+, 2026)

How to implement JWT handling in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: Codable / CryptoKit Updated: May 11, 2026
TL;DR

Split the token on ., Base64URL-decode the second segment, and parse it with a Codable struct to read claims like exp. For signature verification, use CryptoKit's HMAC<SHA256> to authenticate the header+payload against your secret.

struct JWTClaims: Codable {
    let sub: String
    let exp: TimeInterval
}

func decodePayload(_ token: String) throws -> JWTClaims {
    let parts = token.split(separator: ".").map(String.init)
    guard parts.count == 3 else { throw JWTError.malformed }
    let data = try base64URLDecode(parts[1])
    return try JSONDecoder().decode(JWTClaims.self, from: data)
}

var isExpired: Bool {
    (try? decodePayload(token))
        .map { $0.exp < Date().timeIntervalSince1970 } ?? true
}

Full implementation

The implementation wraps all JWT concerns inside an @Observable JWTManager class so any SwiftUI view can react to token changes. It handles Base64URL padding edge cases, HMAC-SHA256 signature verification via CryptoKit, expiry checking, and stores the raw token string in the iOS Keychain using Security framework — never in UserDefaults. Inject the manager as an environment object for app-wide access.

import SwiftUI
import CryptoKit
import Security

// MARK: - Claims model

struct JWTClaims: Codable {
    let sub: String
    let exp: TimeInterval
    let iat: TimeInterval
    let email: String?
}

// MARK: - Errors

enum JWTError: LocalizedError {
    case malformed, invalidBase64, signatureMismatch, expired

    var errorDescription: String? {
        switch self {
        case .malformed:          return "Token does not have three dot-separated segments."
        case .invalidBase64:      return "Payload segment is not valid Base64URL."
        case .signatureMismatch:  return "HMAC-SHA256 signature verification failed."
        case .expired:            return "Token has passed its expiration time."
        }
    }
}

// MARK: - Manager

@Observable
final class JWTManager {
    private(set) var claims: JWTClaims?
    private(set) var isAuthenticated = false

    private let keychainKey = "com.myapp.jwt"

    // Call on launch to restore a persisted token
    func restoreSession() {
        guard let token = loadFromKeychain() else { return }
        try? apply(token: token, secret: nil) // skip sig re-verify on restore
    }

    // Full validate-and-store path (secret = your HMAC signing key bytes)
    func login(token: String, secret: SymmetricKey) throws {
        try verify(token: token, secret: secret)
        try apply(token: token, secret: nil)
        saveToKeychain(token)
    }

    func logout() {
        claims = nil
        isAuthenticated = false
        deleteFromKeychain()
    }

    var isExpired: Bool {
        guard let exp = claims?.exp else { return true }
        return exp < Date().timeIntervalSince1970
    }

    // MARK: Private helpers

    private func apply(token: String, secret: SymmetricKey?) throws {
        let payload = try decodePayload(token)
        claims = payload
        isAuthenticated = !isExpired
    }

    func decodePayload(_ token: String) throws -> JWTClaims {
        let parts = token.split(separator: ".").map(String.init)
        guard parts.count == 3 else { throw JWTError.malformed }
        let data = try base64URLDecode(parts[1])
        let decoder = JSONDecoder()
        return try decoder.decode(JWTClaims.self, from: data)
    }

    func verify(token: String, secret: SymmetricKey) throws {
        let parts = token.split(separator: ".").map(String.init)
        guard parts.count == 3 else { throw JWTError.malformed }
        let signingInput = Data("\(parts[0]).\(parts[1])".utf8)
        let expectedSig = HMAC.authenticationCode(for: signingInput, using: secret)
        let receivedSig = try base64URLDecode(parts[2])
        guard Data(expectedSig) == receivedSig else { throw JWTError.signatureMismatch }
    }

    private func base64URLDecode(_ string: String) throws -> Data {
        var b64 = string
            .replacingOccurrences(of: "-", with: "+")
            .replacingOccurrences(of: "_", with: "/")
        let remainder = b64.count % 4
        if remainder > 0 { b64 += String(repeating: "=", count: 4 - remainder) }
        guard let data = Data(base64Encoded: b64) else { throw JWTError.invalidBase64 }
        return data
    }

    // MARK: Keychain

    private func saveToKeychain(_ token: String) {
        let data = Data(token.utf8)
        let query: [CFString: Any] = [
            kSecClass: kSecClassGenericPassword,
            kSecAttrAccount: keychainKey,
            kSecValueData: data,
            kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
        ]
        SecItemDelete(query as CFDictionary)
        SecItemAdd(query as CFDictionary, nil)
    }

    private func loadFromKeychain() -> String? {
        let query: [CFString: Any] = [
            kSecClass: kSecClassGenericPassword,
            kSecAttrAccount: keychainKey,
            kSecReturnData: true,
            kSecMatchLimit: kSecMatchLimitOne
        ]
        var result: AnyObject?
        guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
              let data = result as? Data else { return nil }
        return String(data: data, encoding: .utf8)
    }

    private func deleteFromKeychain() {
        let query: [CFString: Any] = [
            kSecClass: kSecClassGenericPassword,
            kSecAttrAccount: keychainKey
        ]
        SecItemDelete(query as CFDictionary)
    }
}

// MARK: - Example View

struct AuthStatusView: View {
    @Environment(JWTManager.self) private var jwt

    var body: some View {
        VStack(spacing: 16) {
            if jwt.isAuthenticated, let claims = jwt.claims {
                Label("Signed in as \(claims.sub)", systemImage: "checkmark.seal.fill")
                    .foregroundStyle(.green)
                    .accessibilityLabel("Authenticated user: \(claims.sub)")
                if jwt.isExpired {
                    Text("Session expired — please log in again.")
                        .foregroundStyle(.orange)
                }
                Button("Log out", role: .destructive) { jwt.logout() }
            } else {
                Label("Not authenticated", systemImage: "xmark.seal")
                    .foregroundStyle(.red)
                    .accessibilityLabel("Not authenticated")
            }
        }
        .padding()
        .task { jwt.restoreSession() }
    }
}

// MARK: - Preview

#Preview {
    let manager = JWTManager()
    // Inject a fake decoded state for preview purposes
    return AuthStatusView()
        .environment(manager)
}

How it works

  1. Base64URL decoding (base64URLDecode) — JWTs use a URL-safe alphabet (- and _ instead of + and /) and omit padding. The helper swaps those characters back and re-adds = padding before calling Data(base64Encoded:), which is the standard Foundation decoder.
  2. Codable payload parsing (decodePayload) — After decoding the second dot-segment to raw Data, JSONDecoder maps it into JWTClaims. Any extra fields the server sends are silently ignored because Codable's default strategy does not require exhaustive keys.
  3. HMAC-SHA256 verification (verify)CryptoKit's HMAC<SHA256>.authenticationCode(for:using:) signs header.payload with your SymmetricKey and compares the digest to the decoded third segment. Constant-time comparison is automatic because Data(expectedSig) uses == on two fixed-length byte buffers.
  4. Expiry gate (isExpired) — The exp claim is a Unix timestamp (TimeInterval). Comparing it to Date().timeIntervalSince1970 requires no third-party library and works correctly across time zones.
  5. Keychain persistencekSecAttrAccessibleWhenUnlockedThisDeviceOnly ensures the token is readable only when the device is unlocked and is never included in iCloud backups, which matches the security expectations users have for auth credentials.

Variants

Automatic token refresh with a background task

extension JWTManager {
    /// Call once from your App's .task modifier.
    func startRefreshLoop(
        refreshToken: String,
        refresh: @escaping (String) async throws -> String
    ) async {
        while !Task.isCancelled {
            guard let exp = claims?.exp else { break }
            // Wake up 60 s before expiry to fetch a new token
            let sleepSeconds = max(exp - Date().timeIntervalSince1970 - 60, 0)
            try? await Task.sleep(for: .seconds(sleepSeconds))
            guard !Task.isCancelled else { break }
            if let newToken = try? await refresh(refreshToken) {
                // Re-apply without re-verifying signature (server already validated)
                try? apply(token: newToken, secret: nil)
                saveToKeychain(newToken)
            } else {
                logout()
                break
            }
        }
    }
}

RS256 / asymmetric verification

CryptoKit supports P-256 (ES256) via P256.Signing.PublicKey.isValidSignature(_:for:). Import your server's public key with try P256.Signing.PublicKey(derRepresentation: keyData), then call publicKey.isValidSignature(ecdsaSignature, for: signingData) in verify instead of the HMAC path. RS256 requires the _CryptoExtras package from swift-crypto for _RSA.Signing.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement jwt handling in SwiftUI for iOS 17+.
Use Codable/CryptoKit.
Make it accessible (VoiceOver labels).
Add a #Preview with realistic sample data.

In Soarias' Build phase, paste this prompt with your specific claim fields and secret-key strategy to scaffold a production-ready JWTManager wired into your existing app environment in seconds.

Related

FAQ

Does this work on iOS 16?

The Codable and CryptoKit pieces are backward compatible to iOS 13, but @Observable and the #Preview macro require iOS 17 / Xcode 15+. Swap @Observable for ObservableObject and @Published to support iOS 16, and replace #Preview with a PreviewProvider.

Can I decode a JWT without verifying its signature?

Yes — decodePayload on its own never touches the signature segment. This is fine for reading non-sensitive display claims (like username) from a token your server already validated. Never trust claims for access-control decisions client-side without also verifying the signature, because a malicious actor can craft any payload they like.

What is the UIKit equivalent?

There is no UIKit-specific JWT API — the logic lives entirely in plain Swift. In a UIKit project you would use the same JWTManager class (minus @Observable; use ObservableObject or a plain singleton with NotificationCenter) and call it from your UIViewController or Coordinator.

Last reviewed: 2026-05-11 by the Soarias team.

```