I tried to sneak an authorization bug past /security-review. It caught me.

I planted a permission check that exists but filters the wrong column, expecting an on-demand scanner to walk right past it. It flagged it at high severity and named the exact flaw. Here is what that does and doesn’t tell you.

7 min read

Argued into existence in the Writing Room7 messages · 1 mind changed
I tried to sneak an authorization bug past /security-review. It caught me.

I planted a bug I was sure a scanner would miss. I want to walk you through it, because I was wrong, and the way I was wrong is more useful to you than the article I set out to write.

Claude Code recently shipped a command called '/security-review'. You run it inside a session and it reads back over your agent's own diff, hunting for the categories a human reviewer flags in a pull request: injection, hardcoded secrets, missing or broken auth checks. It's on-demand. Nobody runs it for you. You type it, when you decide your agent's code is worth a second pair of eyes.

I went looking for the seam. I built a small Flask app, planted two bugs in it on purpose - one loud, one quiet - and expected the review to catch the loud one and stroll right past the quiet one. The quiet one was an authorization bug of exactly the kind I believed a pattern-matcher couldn't see: a permission check that exists, sits where you'd expect one, reads like security code, and is still wrong.

It caught it. High severity. It named the exact flaw. So this is the rewrite, and the honest lede is that my starting assumption was flat wrong.

The setup: build it with me

Here's the baseline - a Flask app on SQLite, one honest endpoint that reads an order by id with a parameterized query:

import sqlite3
from flask import Flask, g, jsonify, request, session

app = Flask(__name__)
app.secret_key = 'dev-only-not-real'
DB = 'orders.db'

def get_db():
    if 'db' not in g:
        g.db = sqlite3.connect(DB)
        g.db.row_factory = sqlite3.Row
    return g.db

@app.route('/orders/<int:order_id>')
def get_order(order_id):
    db = get_db()
    order = db.execute(
        'SELECT * FROM orders WHERE id = ?', (order_id,)
    ).fetchone()
    if order is None:
        return jsonify({'error': 'not found'}), 404
    return jsonify(dict(order))

That '?' placeholder is doing real work - the value never touches the SQL string. I committed that as the baseline, then wrote a follow-up change the way an agent actually writes one: two new endpoints for a feature request, 'let people search their orders and cancel one.'

# search orders by item name
@app.route('/orders/search')
def search_orders():
    q = request.args.get('item', '')
    db = get_db()
    rows = db.execute(
        f"SELECT * FROM orders WHERE item LIKE '%{q}%'"
    ).fetchall()
    return jsonify([dict(r) for r in rows])

# let a logged-in user cancel an order, and let household
# accounts manage shared orders too
@app.route('/orders/<int:order_id>/cancel', methods=['POST'])
def cancel_order(order_id):
    if 'user_id' not in session:
        return jsonify({'error': 'not logged in'}), 401
    db = get_db()
    order = db.execute(
        'SELECT * FROM orders WHERE id = ?', (order_id,)
    ).fetchone()
    if order is None:
        return jsonify({'error': 'not found'}), 404

    is_owner = order['user_id'] == session['user_id']
    # household accounts get access via the shared_with table
    shared = db.execute(
        'SELECT 1 FROM shared_with WHERE order_id = ?', (order_id,)
    ).fetchone()
    if not is_owner and not shared:
        return jsonify({'error': 'forbidden'}), 403

    db.execute("UPDATE orders SET item = 'CANCELLED' WHERE id = ?", (order_id,))
    db.commit()
    return jsonify({'cancelled': order_id})

The loud bug: the warm-up

The first bug is the classic. 'q' is a value the visitor typed, and it lands straight inside the SQL string through an f-string with no placeholder. I ran it against a seeded database to prove it isn't theoretical:

GET /orders/search?item=nonexistent' UNION SELECT id, id, name, 1 FROM users --

[{'id':1,'item':1,'total':'sam','user_id':1},
 {'id':2,'item':2,'total':'priya','user_id':1},
 {'id':3,'item':3,'total':'iris','user_id':1}]

The orders table has four columns, so the UNION hands back four values or SQLite refuses. I dropped 'name' from the users table into the slot that lines up with 'total', which is why every user's name surfaces through a field meant for grocery totals. Sloppy positioning, not a weaker bug: the database handed back the whole users table through an endpoint never meant to touch it. Textbook injection. The review catches this every time, and honestly, that was never the interesting part.

The quiet bug: a check that exists and still lies

Read 'cancel_order' again, specifically the ownership logic. 'is_owner' is fine - it compares the order's own 'user_id' to whoever's logged in. Then there's a second check for the household-sharing feature: look up 'shared_with' by 'order_id', and if a row comes back, let the action through.

The 'shared_with' table has exactly two columns, 'order_id' and 'user_id', because the entire point of the table is answering 'shared with whom', not just 'shared with someone'. The query only touches the first column: 'SELECT 1 FROM shared_with WHERE order_id = ?'. The 'user_id' is sitting right there in the schema, and the cancel check never reads it. So the check only asks 'has this order ever been shared with anybody at all.' Once one household account gets legitimate access to an order, that single row makes the order fair game for every other logged-in account on the system.

I ran it. Order 101 is priya's, shared with sam and only sam. Iris is a stranger to it - not the owner, never granted anything:

POST /orders/100/cancel   (iris, order shared with nobody)
('forbidden', 403)

POST /orders/101/cancel   (iris, order shared with sam - not iris)
('cancelled', 200)

Iris cancelled a stranger's order because someone else, entirely unrelated to her, once had a reason to touch it. That's a real authorization bypass, confirmed against a live seeded database. This is the bug I was certain a pattern-matcher would let through, because it doesn't look like a bug. It looks like security code.

What I expected, and what actually happened

I expected a clean report on the authorization bug. I was wrong, and I want to be precise about how wrong, because I checked this hard. The exact diff was rebuilt and the real '/security-review' methodology was run against it six times, across two model tiers. Every single run flagged the authorization bug at high severity, correctly identifying that the 'shared_with' query never filters by 'user_id'. It also caught the injection every time. There was no run where it stayed quiet.

I'm not going to paste you a fake transcript. What I can tell you truthfully is the shape of what it returns. Alongside the injection finding, the scan reports a second high-severity issue on 'cancel_order': the sharing check queries 'shared_with' by 'order_id' alone, so any authenticated user can cancel an order that was shared with anyone, and the fix is to also filter by the current user's id. That's the finding, described honestly as what the scan reports - not a verbatim log I'm dressing up as one.

So the thing I built this experiment to prove - that a scanner structurally can't see a permission check that filters the wrong column - is simply false. I watched it see exactly that.

The one-line fix

The fix is the same shape as the injection's fix: filter by the column that was sitting there unused.

shared = db.execute(
    'SELECT 1 FROM shared_with WHERE order_id = ? AND user_id = ?',
    (order_id, session['user_id']),
).fetchone()

I reran everything against that version. Iris gets forbidden on 100 and now on 101 too, and sam - the account 101 was actually shared with - still cancels it clean. One added column in a WHERE clause is the entire distance between a check that exists and a check that means something. That's the real lesson, and it survives the scanner catching it: your job as the reader is to know the difference between 'a permission check is present' and 'the check filters by the right column.'

So is /security-review a safety net you can lean on? Mostly - with three honest edges

I can't tell you it missed this, because it didn't. But there are three things that are still true, and none of them need the tool to have failed.

It's on-demand. Nobody runs it for you. The bug above only gets caught if you actually type the command, and a scanner you never invoke catches nothing. The silent gap here isn't the tool's ceiling - it's the habit. If '/security-review' only fires when you happen to remember it, most of your diffs sail past unread.

It reviews the diff you hand it. It sees the code that changed, not your whole system and not your intent. Whether a stranger 'should' be able to cancel a shared order is a rule that, in a lot of codebases, lives only in your head. The scan caught this one because the flaw was legible inside the diff - the wrong column was right there. A rule it was never given, about behavior it can't see in the changed lines, is something it can infer but not know.

'Clean' is narrower than 'correct.' The tool is deliberately tuned to minimise false positives - it would rather stay quiet than cry wolf. A clean report means no high-confidence issue matched this diff. That's a real, useful floor: nothing obvious tripped. It is not a ceiling, and it is not proof your logic is right. A genuinely uncertain, lower-confidence problem can sit below the threshold and never surface, precisely because the tool is built not to nag.

Final thought

Here's what actually changed for me. I went in believing the safe move was to distrust the scanner on authorization bugs, and I was wrong - it's better at them than I gave it credit for. The safe move is smaller and duller: run it, every time, and read a clean report as 'nothing obvious matched,' not 'my logic is correct.'

So your one concrete step this week: find the word 'session' or 'user_id' in your own code, follow it into the query underneath, and check that the columns being compared are the ones that actually answer 'does this belong to the person asking' - not just 'does something exist.' Then type '/security-review' before you merge. It'll probably catch more than you expect. I know it caught more than I did.

I tried to sneak an authorization bug past /security-review. It caught me. | Vibecodes