User Authentication with Vapor and SwiftUI

At the end of Series 1, we had a fully deployed and tested Landmarks app. The Vapor backend serves landmarks, the SwiftUI app displays them, and everything is wired together with closure-based dependency injection. But there's a problem: anyone can hit any endpoint. There's no concept of "who" is making a request.

This post adds authentication to both sides. On the server, we'll use JWT tokens and Vapor middleware to protect routes. On the client, we'll build a login flow using the same patterns we've used throughout the series - closure-based services, @Observable stores, and @Entry environment values.

The companion repo has the complete working code: View Code

The Server Side

A User Model

First, we need a user. This is a standard Fluent model with an email and a hashed password. Never store plaintext passwords.

import Fluent
import Vapor

final class User: Model, Content, @unchecked Sendable {
    static let schema = "users"

    @ID(key: .id) var id: UUID?
    @Field(key: "email") var email: String
    @Field(key: "password_hash") var passwordHash: String

    init() {}

    init(id: UUID? = nil, email: String, passwordHash: String) {
        self.id = id
        self.email = email
        self.passwordHash = passwordHash
    }
}

And the migration:

struct CreateUser: AsyncMigration {
    func prepare(on database: Database) async throws {
        try await database.schema("users")
            .id()
            .field("email", .string, .required)
            .field("password_hash", .string, .required)
            .unique(on: "email")
            .create()
    }

    func revert(on database: Database) async throws {
        try await database.schema("users").delete()
    }
}

JWT Setup

Vapor's JWT package handles token creation and verification. Add it to your Package.swift:

.package(url: "https://github.com/vapor/jwt.git", from: "5.0.0"),

Define a payload that carries the user ID and an expiration:

import JWT

struct UserPayload: JWTPayload {
    var subject: SubjectClaim
    var expiration: ExpirationClaim

    func verify(using algorithm: some JWTAlgorithm) throws {
        try expiration.verifyNotExpired()
    }
}

Configure the signing key in your configure.swift. In production, load this from an environment variable:

let signingKey = Environment.get("JWT_SECRET") ?? "dev-secret-change-me"
await app.jwt.keys.add(hmac: signingKey, digestAlgorithm: .sha256)

Auth Routes

Two routes: signup creates a user, login returns a token.

func authRoutes(_ app: Application) throws {
    let auth = app.grouped("auth")

    auth.post("signup") { req async throws -> UserResponse in
        let input = try req.content.decode(AuthInput.self)
        let passwordHash = try Bcrypt.hash(input.password)
        let user = User(email: input.email, passwordHash: passwordHash)
        try await user.save(on: req.db)
        return UserResponse(id: user.id!, email: user.email)
    }

    auth.post("login") { req async throws -> TokenResponse in
        let input = try req.content.decode(AuthInput.self)

        guard let user = try await User.query(on: req.db)
            .filter(\.$email == input.email)
            .first()
        else {
            throw Abort(.unauthorized, reason: "Invalid credentials")
        }

        guard try Bcrypt.verify(input.password, created: user.passwordHash) else {
            throw Abort(.unauthorized, reason: "Invalid credentials")
        }

        let payload = UserPayload(
            subject: .init(value: user.id!.uuidString),
            expiration: .init(value: Date().addingTimeInterval(60 * 60 * 24 * 7)) // 1 week
        )
        let token = try await req.jwt.sign(payload)
        return TokenResponse(token: token)
    }
}

The request and response types are simple:

struct AuthInput: Content {
    let email: String
    let password: String
}

struct UserResponse: Content {
    let id: UUID
    let email: String
}

struct TokenResponse: Content {
    let token: String
}

Protecting Routes with Middleware

Now the key part. Create a middleware that verifies the JWT on every request and injects the user ID into the request:

struct JWTAuthMiddleware: AsyncMiddleware {
    func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
        let payload = try await request.jwt.verify(as: UserPayload.self)

        guard let userId = UUID(uuidString: payload.subject.value) else {
            throw Abort(.unauthorized)
        }

        request.auth.login(AuthenticatedUser(id: userId))
        return try await next.respond(to: request)
    }
}

struct AuthenticatedUser: Authenticatable {
    let id: UUID
}

Apply it to any route group that needs protection:

let protected = app.grouped(JWTAuthMiddleware())

protected.get("landmarks") { req async throws -> [LandmarkResponse] in
    let user = try req.auth.require(AuthenticatedUser.self)
    // user.id is now available for user-specific queries
    let landmarks = try await Landmark.query(on: req.db).all()
    return landmarks.map { LandmarkResponse(from: $0) }
}

The landmarks endpoint still returns the same data, but now it requires a valid token. Unauthenticated requests get a 401.

The Client Side

Keychain Storage

Tokens belong in the Keychain, not UserDefaults. Here's a minimal helper:

import Security

enum KeychainHelper {
    static func save(_ data: Data, for key: String) {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
            kSecValueData as String: data
        ]
        SecItemDelete(query as CFDictionary)
        SecItemAdd(query as CFDictionary, nil)
    }

    static func load(for key: String) -> Data? {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
            kSecReturnData as String: true,
            kSecMatchLimit as String: kSecMatchLimitOne
        ]
        var result: AnyObject?
        SecItemCopyMatching(query as CFDictionary, &result)
        return result as? Data
    }

    static func delete(for key: String) {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key
        ]
        SecItemDelete(query as CFDictionary)
    }
}

The Auth Service

Following the pattern from Post 3, the auth service is a struct with closure properties:

struct AuthService: Sendable {
    var login: @Sendable (_ email: String, _ password: String) async throws -> String
    var signup: @Sendable (_ email: String, _ password: String) async throws -> Void
    var loadToken: @Sendable () -> String?
    var clearToken: @Sendable () -> Void
}

extension AuthService {
    static let live = AuthService(
        login: { email, password in
            let input = AuthInput(email: email, password: password)
            let data = try JSONEncoder().encode(input)

            var request = URLRequest(url: URL(string: "\(API.baseURL)/auth/login")!)
            request.httpMethod = "POST"
            request.httpBody = data
            request.setValue("application/json", forHTTPHeaderField: "Content-Type")

            let (responseData, _) = try await URLSession.shared.data(for: request)
            let response = try JSONDecoder().decode(TokenResponse.self, from: responseData)

            let tokenData = Data(response.token.utf8)
            KeychainHelper.save(tokenData, for: "auth-token")

            return response.token
        },
        signup: { email, password in
            let input = AuthInput(email: email, password: password)
            let data = try JSONEncoder().encode(input)

            var request = URLRequest(url: URL(string: "\(API.baseURL)/auth/signup")!)
            request.httpMethod = "POST"
            request.httpBody = data
            request.setValue("application/json", forHTTPHeaderField: "Content-Type")

            let (_, response) = try await URLSession.shared.data(for: request)
            guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
                throw AuthError.signupFailed
            }
        },
        loadToken: {
            guard let data = KeychainHelper.load(for: "auth-token") else { return nil }
            return String(data: data, encoding: .utf8)
        },
        clearToken: {
            KeychainHelper.delete(for: "auth-token")
        }
    )

    static let unimplemented = AuthService(
        login: { _, _ in fatalError("AuthService.login not implemented") },
        signup: { _, _ in fatalError("AuthService.signup not implemented") },
        loadToken: { fatalError("AuthService.loadToken not implemented") },
        clearToken: { fatalError("AuthService.clearToken not implemented") }
    )
}

Register it in the environment using @Entry:

extension EnvironmentValues {
    @Entry var authService: AuthService = .unimplemented
}

The Auth Store

An @Observable store manages the auth state. Views observe this directly - no view model needed.

@Observable
final class AuthStore {
    var isAuthenticated = false
    var isLoading = false
    var error: String?

    private let service: AuthService

    init(service: AuthService) {
        self.service = service
        self.isAuthenticated = service.loadToken() != nil
    }

    func login(email: String, password: String) async {
        isLoading = true
        error = nil
        do {
            _ = try await service.login(email, password)
            isAuthenticated = true
        } catch {
            self.error = "Invalid email or password"
        }
        isLoading = false
    }

    func signup(email: String, password: String) async {
        isLoading = true
        error = nil
        do {
            try await service.signup(email, password)
            _ = try await service.login(email, password)
            isAuthenticated = true
        } catch {
            self.error = "Could not create account"
        }
        isLoading = false
    }

    func logout() {
        service.clearToken()
        isAuthenticated = false
    }
}

The Login View

struct LoginView: View {
    @Environment(\.authService) private var authService
    @State private var store: AuthStore?
    @State private var email = ""
    @State private var password = ""
    @State private var isSignup = false

    var body: some View {
        let authStore = store ?? AuthStore(service: authService)

        VStack(spacing: 24) {
            Text(isSignup ? "Create Account" : "Sign In")
                .font(.largeTitle.bold())

            VStack(spacing: 16) {
                TextField("Email", text: $email)
                    .textContentType(.emailAddress)
                    .autocorrectionDisabled()
                    .textInputAutocapitalization(.never)
                    .textFieldStyle(.roundedBorder)

                SecureField("Password", text: $password)
                    .textContentType(isSignup ? .newPassword : .password)
                    .textFieldStyle(.roundedBorder)
            }

            if let error = authStore.error {
                Text(error)
                    .foregroundStyle(.red)
                    .font(.callout)
            }

            Button {
                Task {
                    if isSignup {
                        await authStore.signup(email: email, password: password)
                    } else {
                        await authStore.login(email: email, password: password)
                    }
                }
            } label: {
                if authStore.isLoading {
                    ProgressView()
                        .frame(maxWidth: .infinity)
                } else {
                    Text(isSignup ? "Sign Up" : "Sign In")
                        .frame(maxWidth: .infinity)
                }
            }
            .buttonStyle(.borderedProminent)
            .disabled(email.isEmpty || password.isEmpty || authStore.isLoading)

            Button(isSignup ? "Already have an account? Sign In" : "Don't have an account? Sign Up") {
                isSignup.toggle()
            }
            .font(.callout)
        }
        .padding()
        .onAppear { store = authStore }
    }
}

Adding the Token to Network Requests

The existing LandmarkService from Series 1 needs to send the token with every request. Update the live implementation to read from Keychain:

extension LandmarkService {
    static func authenticated(authService: AuthService) -> LandmarkService {
        LandmarkService(
            fetchAll: {
                var request = URLRequest(url: URL(string: "\(API.baseURL)/landmarks")!)
                if let token = authService.loadToken() {
                    request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
                }
                let (data, _) = try await URLSession.shared.data(for: request)
                return try JSONDecoder().decode([LandmarkResponse].self, from: data)
                    .map { $0.toDomain() }
            }
        )
    }
}

Gating the App on Auth State

In your root view, check if the user is authenticated:

struct ContentView: View {
    @State private var authStore: AuthStore

    init(authService: AuthService) {
        _authStore = State(initialValue: AuthStore(service: authService))
    }

    var body: some View {
        if authStore.isAuthenticated {
            LandmarksNavigationView()
                .toolbar {
                    ToolbarItem(placement: .topBarTrailing) {
                        Button("Sign Out") {
                            authStore.logout()
                        }
                    }
                }
        } else {
            LoginView()
        }
    }
}

Testing

Because the auth service uses closures, testing is straightforward. Swap in a mock that returns a known token:

@Test func loginStoresTokenAndSetsAuthenticated() async {
    let mockService = AuthService(
        login: { _, _ in "mock-jwt-token" },
        signup: { _, _ in },
        loadToken: { "mock-jwt-token" },
        clearToken: { }
    )

    let store = AuthStore(service: mockService)
    await store.login(email: "test@test.com", password: "password")

    #expect(store.isAuthenticated == true)
    #expect(store.error == nil)
}

@Test func loginWithBadCredentialsShowsError() async {
    let mockService = AuthService(
        login: { _, _ in throw AuthError.invalidCredentials },
        signup: { _, _ in },
        loadToken: { nil },
        clearToken: { }
    )

    let store = AuthStore(service: mockService)
    await store.login(email: "test@test.com", password: "wrong")

    #expect(store.isAuthenticated == false)
    #expect(store.error != nil)
}

What's Next

With authentication in place, we know who each user is. That unlocks the next feature: sending them push notifications. In the next post, we'll integrate APNSwift into the Vapor backend to deliver real-time updates when landmarks change or reservations are confirmed.