How to implement JWT handling in SwiftUI
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
-
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 callingData(base64Encoded:), which is the standard Foundation decoder. -
Codable payload parsing (
decodePayload) — After decoding the second dot-segment to rawData,JSONDecodermaps it intoJWTClaims. Any extra fields the server sends are silently ignored becauseCodable's default strategy does not require exhaustive keys. -
HMAC-SHA256 verification (
verify) —CryptoKit'sHMAC<SHA256>.authenticationCode(for:using:)signsheader.payloadwith yourSymmetricKeyand compares the digest to the decoded third segment. Constant-time comparison is automatic becauseData(expectedSig)uses==on two fixed-length byte buffers. -
Expiry gate (
isExpired) — Theexpclaim is a Unix timestamp (TimeInterval). Comparing it toDate().timeIntervalSince1970requires no third-party library and works correctly across time zones. -
Keychain persistence —
kSecAttrAccessibleWhenUnlockedThisDeviceOnlyensures 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
-
iOS 16 compatibility:
@Observableand the#Previewmacro require iOS 17 / Xcode 15+. If you need iOS 16 support you must use@ObservableObject/@PublishedandPreviewProviderinstead. -
Missing Base64 padding: JWT segments intentionally omit trailing
=characters. Forgetting to re-add padding before callingData(base64Encoded:)will silently returnnil— always normalise the string first as shown inbase64URLDecode. - Storing the secret in the binary: Hardcoding the HMAC secret in Swift source code exposes it to binary extraction. Prefer fetching the key from a server-side endpoint over TLS at login time, or use asymmetric (ES256/RS256) verification where only the public key ships with the app.
-
Clock skew on expiry checks: Device clocks can drift by several minutes. Add a
small grace buffer (e.g. 30 s) when comparing
exptoDate()to avoid false "expired" rejections immediately after login.
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.