Authentication

This page is optional. The app from the previous five pages is fully functional — anyone can create and browse notes. This page adds user accounts so that each person only sees their own notes.

By the end you'll have registration, login, token-based middleware, and protected routes — with SHA-256 password hashing and cryptographically secure random tokens.

Step 1 — Schema changes

Update src/db/schema.ks to add users and tokens tables, and a user_id column on notes:

public func initSchema(path: String) -> () throws SqliteError { let db = try Database(path); try db.execute(""" CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, salt TEXT NOT NULL, password_hash TEXT NOT NULL ) """); try db.execute(""" CREATE TABLE IF NOT EXISTS tokens ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, token TEXT NOT NULL UNIQUE ) """); try db.execute(""" CREATE TABLE IF NOT EXISTS notes ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, body TEXT NOT NULL, created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE ) """); .Ok(()) }

If you already have a notes.db file from earlier, delete it so the schema is created fresh.

Step 2 — User models

Create src/models/user.ks:

module notes.models import talon.sqlite.row.(Row, FromRow) import talon.sqlite.error.(SqliteError) import quill.value.(Value) import quill.serialize.(Serialize) import quill.error.(SerializeError) public struct User: FromRow, Serialize, Cloneable { public var id: Int64 public var email: String public static func fromRow(row: Row) -> User throws SqliteError { User( id: try row.read[Int64](at: 0), email: try row.read[String](at: 1) ) } public func toValue() -> Result[Value, SerializeError] { var obj = Dictionary[String, Value](); obj.insert("id", Value.Int(self.id)); obj.insert("email", Value.Str(self.email)); .Ok(Value.Obj(obj)) } public func clone() -> User { User(id: self.id, email: self.email.clone()) } } public struct PasswordRow: FromRow, Cloneable { public var id: Int64 public var salt: String public var passwordHash: String public static func fromRow(row: Row) -> PasswordRow throws SqliteError { PasswordRow( id: try row.read[Int64](at: 0), salt: try row.read[String](at: 1), passwordHash: try row.read[String](at: 2) ) } public func clone() -> PasswordRow { PasswordRow(id: self.id, salt: self.salt.clone(), passwordHash: self.passwordHash.clone()) } } public struct AuthToken: FromRow, Cloneable { public var token: String public var userId: Int64 public static func fromRow(row: Row) -> AuthToken throws SqliteError { AuthToken( token: try row.read[String](at: 0), userId: try row.read[Int64](at: 1) ) } public func clone() -> AuthToken { AuthToken(token: self.token.clone(), userId: self.userId) } }

User is what we return to clients (no password data). PasswordRow is what we read to verify a login. AuthToken maps the tokens table.

Step 3 — Password hashing

We need to hash passwords before storing them. The crypto library provides SHA-256 and a cryptographically secure random number generator. Create src/crypto/hashing.ks:

module notes.crypto import crypto.digest.(SHA256) import crypto.random.(randomBytes) public func hashPassword(password: String, salt: String) -> String { var h = SHA256(); h.update(salt.asByteSlice()); h.update(password.asByteSlice()); h.finalize().hexString } public func generateSalt() -> String { var h = SHA256(); h.update(randomBytes(count: 32)); h.finalize().hexString } public func generateToken() -> String { var h = SHA256(); h.update(randomBytes(count: 32)); h.finalize().hexString }

A few things to notice about the crypto library:

  • SHA256 conforms to the Digest protocol. You create a hasher, feed in data with update (a positional parameter), and call finalize to get the digest.
  • update is generic over any Slice[UInt8]. Array[UInt8] is one, so randomBytes' output passes straight in; asByteSlice() gives a string's UTF-8 bytes as one without copying.
  • finalize() returns a DigestOutput with a .hexString property — a lowercase hex encoding of the 32-byte hash.
  • randomBytes(count: 32) calls the OS's cryptographic RNG (arc4random_buf) to produce 32 unpredictable bytes — unlike a general-purpose hash of a counter or timestamp, the output can't be predicted.

hashPassword feeds the salt and password into SHA-256 sequentially. The order matters: salt first prevents rainbow table attacks. generateSalt and generateToken each hash 32 random bytes into a 64-character hex string.

Step 4 — User and token queries

Create src/db/users.ks:

module notes.db import talon.sqlite.executor.(SqliteExecutor) import talon.sqlite.error.(SqliteError) import notes.models.(User, PasswordRow) public func findUserByEmail(db: some SqliteExecutor, email: String) -> User? throws SqliteError { let rows = try db.query[User](""" SELECT id, email FROM users WHERE email = \(email) """); if rows.count > 0 { .Ok(.Some(rows(0))) } else { .Ok(.None) } } public func findPasswordByEmail(db: some SqliteExecutor, email: String) -> PasswordRow? throws SqliteError { let rows = try db.query[PasswordRow](""" SELECT id, salt, password_hash FROM users WHERE email = \(email) """); if rows.count > 0 { .Ok(.Some(rows(0))) } else { .Ok(.None) } } public func createUser(db: some SqliteExecutor, email: String, salt: String, passwordHash: String) -> User throws SqliteError { try db.execute(""" INSERT INTO users (email, salt, password_hash) VALUES (\(email), \(salt), \(passwordHash)) """); let rows = try db.query[User](""" SELECT id, email FROM users WHERE rowid = last_insert_rowid() """); .Ok(rows(0)) }

Create src/db/tokens.ks:

module notes.db import talon.sqlite.executor.(SqliteExecutor) import talon.sqlite.error.(SqliteError) import notes.models.(AuthToken) public func lookupToken(db: some SqliteExecutor, token: String) -> Int64? throws SqliteError { let rows = try db.query[AuthToken](""" SELECT token, user_id FROM tokens WHERE token = \(token) """); if rows.count > 0 { .Ok(.Some(rows(0).userId)) } else { .Ok(.None) } } public func createToken(db: some SqliteExecutor, userId: Int64, token: String) -> () throws SqliteError { try db.execute(""" INSERT INTO tokens (user_id, token) VALUES (\(userId), \(token)) """); .Ok(()) }

Step 5 — Update note queries

Notes are now scoped to a user. Update src/db/notes.ks to add a userId parameter:

public func listNotes(db: some SqliteExecutor, userId: Int64) -> Array[Note] throws SqliteError { db.query[Note](""" SELECT id, title, body, created_at FROM notes WHERE user_id = \(userId) ORDER BY created_at DESC """) } public func findNoteById(db: some SqliteExecutor, id noteId: Int64, userId userId: Int64) -> Note? throws SqliteError { let rows = try db.query[Note](""" SELECT id, title, body, created_at FROM notes WHERE id = \(noteId) AND user_id = \(userId) """); if rows.count > 0 { .Ok(.Some(rows(0))) } else { .Ok(.None) } } public func createNote(db: some SqliteExecutor, title: String, body: String, userId: Int64) -> Note throws SqliteError { try db.execute(""" INSERT INTO notes (title, body, user_id) VALUES (\(title), \(body), \(userId)) """); let rows = try db.query[Note](""" SELECT id, title, body, created_at FROM notes WHERE rowid = last_insert_rowid() """); .Ok(rows(0)) } public func updateNote(db: some SqliteExecutor, id noteId: Int64, title: String, body: String, userId: Int64) -> Note? throws SqliteError { try db.execute(""" UPDATE notes SET title = \(title), body = \(body) WHERE id = \(noteId) AND user_id = \(userId) """); let rows = try db.query[Note](""" SELECT id, title, body, created_at FROM notes WHERE id = \(noteId) AND user_id = \(userId) """); if rows.count > 0 { .Ok(.Some(rows(0))) } else { .Ok(.None) } } public func deleteNote(db: some SqliteExecutor, id noteId: Int64, userId userId: Int64) -> () throws SqliteError { try db.execute(""" DELETE FROM notes WHERE id = \(noteId) AND user_id = \(userId) """); .Ok(()) }

Every query now includes AND user_id = \(userId). A user can only see, edit, or delete their own notes.

Step 6 — Auth request types

Create src/requests/auth.ks:

module notes.requests import quill.value.(Value) import quill.deserialize.(Deserialize, extractString) import quill.error.(DeserializeError) public struct RegisterRequest: Deserialize, Cloneable { public var email: String public var password: String public static func fromValue(value: Value) -> Result[RegisterRequest, DeserializeError] { let email = try extractString(from: value, "email"); let password = try extractString(from: value, "password"); .Ok(RegisterRequest(email: email, password: password)) } public func clone() -> RegisterRequest { RegisterRequest(email: self.email.clone(), password: self.password.clone()) } } public struct LoginRequest: Deserialize, Cloneable { public var email: String public var password: String public static func fromValue(value: Value) -> Result[LoginRequest, DeserializeError] { let email = try extractString(from: value, "email"); let password = try extractString(from: value, "password"); .Ok(LoginRequest(email: email, password: password)) } public func clone() -> LoginRequest { LoginRequest(email: self.email.clone(), password: self.password.clone()) } }

Step 7 — Auth handlers

Create src/handlers/auth.ks:

module notes.handlers import perch.request.(Request) import perch.response.(Response) import perch.json_body.(JsonBody) import quill.value.(Value) import notes.context.(AppCtx) import notes.helpers.(errorJson, parseBody) import notes.requests.(RegisterRequest, LoginRequest) import notes.db.(findUserByEmail, findPasswordByEmail, createUser, createToken) import notes.crypto.(hashPassword, generateSalt, generateToken) public func handleRegister(req: Request, ctx: AppCtx) -> Response { let body = match parseBody[RegisterRequest](req.body) { .Ok(b) => b, .Err(resp) => return resp }; let db = ctx.db; guard let .Ok(existingUser) = findUserByEmail(db, body.email) else { return Response.internalServerError() } if let .Some(_) = existingUser { return Response.conflict(JsonBody(fromRaw: errorJson("Email already registered"))) }; let salt = generateSalt(); let passwordHash = hashPassword(body.password, salt); guard let .Ok(user) = createUser(db, body.email, salt, passwordHash) else { return Response.internalServerError() } guard let .Ok(json) = JsonBody(user) else { return Response.internalServerError() } Response.created(json) } public func handleLogin(req: Request, ctx: AppCtx) -> Response { let body = match parseBody[LoginRequest](req.body) { .Ok(b) => b, .Err(resp) => return resp }; let db = ctx.db; guard let .Ok(some passwordRow) = findPasswordByEmail(db, body.email) else { return Response.unauthorized() } let hash = hashPassword(body.password, passwordRow.salt); guard hash == passwordRow.passwordHash else { return Response.unauthorized() } let token = generateToken(); guard let .Ok(_) = createToken(db, passwordRow.id, token.clone()) else { return Response.internalServerError() } var obj = Dictionary[String, Value](); obj.insert("token", Value.Str(token)); Response.ok(JsonBody(fromRaw: Value.Obj(obj))) }

These handlers follow the same pattern as the note handlers: parse the body, grab the database, do the work, return JSON. The guard let chains make the happy path read top-to-bottom.

A security property worth pausing on: generateSalt() and generateToken() both draw from crypto.random.randomBytes — each call produces 32 cryptographically secure random bytes, hashed through SHA-256. Neither the salt nor the token is derived from user data like an email or an ID, so both are truly unpredictable.

Step 8 — Middleware

Create src/middleware/auth.ks:

module notes.middleware import perch.request.(Request) import perch.response.(Response) import perch.context.(MiddlewareResult, Middleware) import notes.context.(AppCtx) import notes.db.(lookupToken) public struct AuthMiddleware: Middleware[AppCtx], Cloneable { public func handle(request: Request, ctx: AppCtx) -> MiddlewareResult { guard let .Some(authHeader) = request.header("Authorization") else { return .Respond(Response.unauthorized()) } guard authHeader.starts(with: "Bearer ") else { return .Respond(Response.unauthorized()) } let token = authHeader.asSlice().subslice(from: 7, to: authHeader.byteCount).toOwned(); let db = ctx.db; guard let .Ok(some userId) = lookupToken(db, token) else { return .Respond(Response.unauthorized()) } var req = request; req.store.insert("userId", "\(userId)"); .Continue(req) } public func clone() -> AuthMiddleware { AuthMiddleware() } }

Middleware[AppCtx] is a protocol with one method: handle, which receives the request and context and returns a MiddlewareResult. That's an enum with two cases:

  • .Continue(req) — pass the (possibly modified) request to the next handler in the chain.
  • .Respond(response) — short-circuit and send a response immediately.

The middleware does four things in sequence:

  1. Checks for an Authorization header. No header -> 401.
  2. Checks that it starts with "Bearer ". Wrong format -> 401.
  3. Extracts the token string using subslice (everything after the 7-byte prefix "Bearer "), looks it up in the database. Invalid token -> 401.
  4. Inserts the user ID into the request's store dictionary, so handlers downstream can read it.

Step 9 — Route groups

Update src/main.ks to protect the note routes with middleware. Add a requireUserId helper to src/helpers.ks:

public func requireUserId(store: Dictionary[String, String]) -> Int64? { guard let .Some(id) = store("userId") else { return .None } Int64(parsing: id) }

Then update src/main.ks with route groups:

import perch.router.(GroupBuilder) import notes.middleware.(AuthMiddleware) import notes.handlers.(handleRegister, handleLogin)

Replace the flat note routes with a group:

// Public auth routes app.route(post: "/auth/register", handleRegister); app.route(post: "/auth/login", handleLogin); // Authenticated API routes var apiRoutes = GroupBuilder[AppCtx]("/api/notes"); apiRoutes.use(AuthMiddleware()); apiRoutes.route(get: "", handleListNotes); apiRoutes.route(post: "", handleCreateNote); apiRoutes.route(get: "/:id", handleGetNote); apiRoutes.route(post: "/:id", handleUpdateNote); apiRoutes.route(delete: "/:id", handleDeleteNote); app.addGroup(apiRoutes);

GroupBuilder takes a path prefix — "/api/notes" — and groups routes under it. Calling apiRoutes.use(AuthMiddleware()) applies the middleware to every route in the group, but not to routes outside it. The auth routes remain public.

You'll also need to update every note handler to read the user ID:

guard let .Some(userId) = requireUserId(req.store) else { return Response.unauthorized() }

And pass userId through to the query functions — for example, listNotes(db, userId) instead of listNotes(db).

Step 10 — Test it

# This should return 401 Unauthorized curl -i http://localhost:8080/api/notes # Register curl -X POST http://localhost:8080/auth/register \ -H "Content-Type: application/json" \ -d '{"email":"alice@example.com","password":"secret123"}' # Login (save the token) TOKEN=$(curl -s -X POST http://localhost:8080/auth/login \ -H "Content-Type: application/json" \ -d '{"email":"alice@example.com","password":"secret123"}' \ | grep -o '"token":"[^"]*"' | cut -d'"' -f4) # Now use the token curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/notes # Create a note as Alice curl -X POST http://localhost:8080/api/notes \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"title":"Private note","body":"Only Alice can see this."}' # List notesonly Alice's notes appear curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/notes

What you saw

StepFeature
1Schema evolution: adding tables and columns
2Multiple model structs with different protocol conformances
3SHA256 for password hashing, randomBytes for salt/token generation
4–5Query functions with user scoping
6Deserialize for auth request types
7Auth handler pattern: parse, validate, create token
8Middleware[T] protocol, MiddlewareResult enum, string slicing
9GroupBuilder for route prefixes, scoped middleware

The crypto library brings real security to authentication. randomBytes pulls from the OS's cryptographic RNG, SHA-256 produces a proper one-way hash, and each user gets a unique random salt. Middleware is the pattern for cross-cutting concerns — authentication, logging, rate limiting. Define a struct, conform to Middleware[T], and apply it to a route group. The handler code stays clean.