Skip to Content
Course content

98: Authentication in Vapor Applications

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've got this "Secret Notes" API almost finished, but I've run into a glaring problem: right now, any client that knows a note's ID can read or delete it. There's no identity check. If I'm building this for a production environment, that's essentially an invitation for a data breach. We need to lock this down so only the person who created the note can touch it.

The Open Door Problem

Right now, my route looks something like this:

app.get("notes", ":noteID") { req -> EventLoopFuture<Note> in
    let id = req.parameters.get("noteID", as: UUID.self)
    return Note.find(id, on: req.db).unwrap(or: Abort(.notFound))
}

If I hit this endpoint with CURL, it just works. No questions asked. I want to change this so the request must prove who the user is first. In Vapor, the standard way to handle this is through the Authenticatable protocol. I'll start by updating my User model.

struct User: Model, Authenticatable {
    static let schema = "users"
    // ... id, username, password hash ...
}

Adding Authenticatable tells Vapor, "This model can be used to represent a logged-in user." But just adding the protocol doesn't actually stop anyone from accessing the route. I tried adding req.auth.get(User.self) inside the handler, but it just returned nil because I hadn't told Vapor how to find the user from the request.

Finding the Identity in the Header

Since this is a stateless API, I don't want to deal with cookies or sessions. I'll use JWTs (JSON Web Tokens). I've added the JWT package to my Package.swift, but now I need a payload. I realized I can't just use the User model directly as the token because the token needs to be a lightweight piece of data, not a database object.

struct UserPayload: JWTPayload {
    var subject: SubjectClaim
    var expiration: ExpirationClaim
    
    func verify(using signer: JWTSigner) throws {
        try expiration.verifyNotExpired()
    }
}

Now, I need a way to bridge the gap between a valid token and my User model. I'll create a custom authenticator. I tried doing this manually inside the route handler at first, but that's a nightmare for maintenance. Instead, I'll use a Request extension or a middleware. Actually, Vapor has a built-in JWTAuthenticator that does the heavy lifting.

struct UserAuthenticator: JWTAuthenticator {
    typealias Payload = UserPayload

    func authenticate(jwt: UserPayload, for request: Request) -> EventLoopFuture<Void> {
        return User.query(on: request.db)
            .filter(\.$id == jwt.subject.value)
            .first()
            .map { user in
                if let user = user {
                    request.auth.login(user)
                }
            }
    }
}

I noticed a bug here during testing: if the token was valid but the user had been deleted from the database, the request still proceeded. I had to make sure request.auth.login(user) only happened if the user actually existed. If it's nil, the authentication fails, which is exactly what we want.

Locking the Door with Middleware

Now that I have an authenticator, I can't just apply it to every route—I still need a way for new users to sign up or log in without being authenticated. I'll create a grouped set of routes that are "guarded."

let protected = app.grouped(UserAuthenticator())
    .grouped(User.guardMiddleware())

protected.get("notes", ":noteID") { req -> EventLoopFuture<Note> in
    let user = try req.auth.require(User.self)
    let id = req.parameters.get("noteID", as: UUID.self)
    
    return Note.find(id, on: req.db)
        .unwrap(or: Abort(.notFound))
        .flatMapThrowing { note in
            guard note.userID == user.id else {
                throw Abort(.forbidden)
            }
            return note
        }
}

I spent a few minutes wondering why my request was still hitting the handler even when I didn't provide a token. I realized I forgot the User.guardMiddleware(). The UserAuthenticator simply tries to log the user in; it doesn't actually block the request if it fails. The guardMiddleware is the bouncer that actually throws a 401 Unauthorized if the authentication didn't work.

One last detail: the logic note.userID == user.id is crucial. Authentication proves who you are, but Authorization proves what you're allowed to do. Even with a valid token, I shouldn't be able to read someone else's notes.




📋 Practical Task

Building a Restricted Admin Analytics Endpoint

You are tasked with adding an administrative feature to the Secret Notes API. You need to create a route GET /admin/stats that returns the total number of notes stored in the database across all users.

  • Requirement 1: The route must be protected by the UserAuthenticator and User.guardMiddleware() so that only logged-in users can access it.
  • Requirement 2: Implement a check to ensure that only users with an isAdmin boolean property set to true can access this route. If a regular user attempts to access it, return a 403 Forbidden error.
  • Requirement 3: The endpoint should return a simple JSON object containing the count of all notes in the Note table.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.