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.