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:
SHA256conforms to theDigestprotocol. You create a hasher, feed in data withupdate(a positional parameter), and callfinalizeto get the digest.updateis generic over anySlice[UInt8].Array[UInt8]is one, sorandomBytes' output passes straight in;asByteSlice()gives a string's UTF-8 bytes as one without copying.finalize()returns aDigestOutputwith a.hexStringproperty — 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:
- Checks for an
Authorizationheader. No header -> 401. - Checks that it starts with
"Bearer ". Wrong format -> 401. - Extracts the token string using
subslice(everything after the 7-byte prefix"Bearer "), looks it up in the database. Invalid token -> 401. - 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 notes — only Alice's notes appear
curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/notes
What you saw
| Step | Feature |
|---|---|
| 1 | Schema evolution: adding tables and columns |
| 2 | Multiple model structs with different protocol conformances |
| 3 | SHA256 for password hashing, randomBytes for salt/token generation |
| 4–5 | Query functions with user scoping |
| 6 | Deserialize for auth request types |
| 7 | Auth handler pattern: parse, validate, create token |
| 8 | Middleware[T] protocol, MiddlewareResult enum, string slicing |
| 9 | GroupBuilder 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.