Application patterns
Build validated and authorized CRUD
Keep transport validation, tenant authorization, database constraints, and safe responses as separate layers.
A CRUD endpoint on D1 has four layers, and each one catches a different mistake. Validation rejects malformed input. Authentication tells you who is calling. Authorization limits what they can touch. The database constraints catch whatever slipped through. Keep them separate and the code stays easy to reason about.
Validate before you query
Parse the JSON, check the shape, and reject early:
const body = await request.json().catch(() => null)
if (!body || typeof body.title !== 'string' || body.title.length > 200) {
return new Response('Invalid title', { status: 400 })
}
The database would reject a 300-character title too, thanks to the CHECK constraint. But a 400 with a clear message is a better answer than a 500 caused by a constraint error.
Put ownership in the SQL
The caller’s identity comes from your auth layer. A session cookie, a verified token. Never from the request body or a query string. Then the ownership check goes inside the query itself.
Bind values instead of building SQL strings:
const note = await env.DB
.prepare('select id, title from notes where id = ? and user_id = ?')
.bind(noteId, userId)
.first()
The user ID must come from a verified identity, not a request field. Test a valid owner, another user, a missing note, and a malformed ID. Parameter binding protects the SQL boundary; the ownership predicate protects the authorization boundary.
Notice it’s one query. Checking ownership with a select and then updating with a second statement opens a gap. The row can change in between, and now you have two places to keep in sync.
Updates and deletes follow the same shape:
const { meta } = await env.DB
.prepare('delete from notes where id = ? and user_id = ?')
.bind(noteId, userId)
.run()
if (meta.changes === 0) {
return new Response('Not found', { status: 404 })
}
Answer 404, not 403
When a user asks for a note they don’t own, return 404. A 403 tells them the note exists and belongs to someone else. That’s information they should not have. From the outside, “not yours” and “does not exist” must look the same.
Keep database errors out of responses too. D1_ERROR: UNIQUE constraint failed: notes.slug is useful in your logs, next to the operation name and a request ID. In an HTTP body it leaks your schema. Return a generic message and log the real one.
Try this on the notes API: implement update and delete with both IDs in the WHERE clause, then call them as the owner, as another user, with a note that doesn’t exist, and with id=abc. You want 200, 404, 404 and 400, in that order.
Lesson completed