July 17, 2026
Can AI write secure code? I audited 50 AI-Generated snippets
A real, hands-on audit — methodology, sources, and the actual code, broken down line by line.

By h@shtalk
4 min read
"Can AI write secure code" is a question that gets answered with vibes far more often than with actual auditing. So I ran my own audit, and I want to show the actual work this time, not just the conclusions.
The methodology, specifically
I generated 50 code snippets myself, across four categories I see most often in real production codebases — authentication logic, database queries, file upload/handling, and REST API endpoint code.
I used a consistent rotation of prompts across three commonly used coding assistants (a general-purpose chat model, an IDE-integrated assistant, and a CLI-based code generation tool), asking for realistic, production-style functions rather than toy examples — "write a login endpoint for a Flask app," "write a file upload handler that saves user-submitted images," that kind of framing, deliberately ordinary and exactly the kind of prompt a developer under deadline pressure actually types.
I then reviewed every snippet the way I'd review a junior developer's pull request: read it cold, flag anything I'd block in review, and only then check it against the OWASP Top 10 categories to see if my gut reaction matched a known class of flaw. 13 of the 50 had at least one issue I'd block in a real review. That's roughly one in four — not catastrophic, but not nothing, and the pattern of where the failures clustered is the actually useful part.
The good news first
For well-known, heavily-represented patterns, the output was frequently solid. Here's a representative login query, generated cleanly across nearly every prompt variation I tried:
# Generated consistently correct across all three tools
def get_user_by_email(db, email):
query = "SELECT id, password_hash FROM users WHERE email = %s"
cursor = db.execute(query, (email,))
return cursor.fetchone()# Generated consistently correct across all three tools
def get_user_by_email(db, email):
query = "SELECT id, password_hash FROM users WHERE email = %s"
cursor = db.execute(query, (email,))
return cursor.fetchone()Parameterized, no string concatenation, no obvious SQL injection surface. This is a problem so well-documented and so heavily represented in training data that getting it right is, at this point, closer to the default than the exception. Password hashing via standard libraries (bcrypt, argon2) instead of homegrown schemes showed up reliably too. If you're worried about an assistant suggesting you concatenate raw input into a SQL string in 2026, that's mostly not where the actual risk lives anymore.
Where it consistently broke down
1. Authorization, not authentication
This was the single largest cluster — roughly a third of the endpoint-handling snippets had this exact shape. Here's an actual generated snippet, lightly trimmed, from a prompt asking for "an endpoint to fetch a user's order details by order ID":
@app.route("/orders/<order_id>", methods=["GET"])
@login_required
def get_order(order_id):
order = db.query(Order).filter_by(id=order_id).first()
if not order:
return jsonify({"error": "Not found"}), 404
return jsonify(order.to_dict())@app.route("/orders/<order_id>", methods=["GET"])
@login_required
def get_order(order_id):
order = db.query(Order).filter_by(id=order_id).first()
if not order:
return jsonify({"error": "Not found"}), 404
return jsonify(order.to_dict())Look closely. @login_required checks that someone is logged in. Nothing here checks that the logged-in user is the one who actually owns order_id. Any authenticated user can pull anyone else's order just by incrementing the ID in the URL — a textbook broken object-level authorization flaw (this is literally OWASP API Security's #1 category). The fix is one line:
@app.route("/orders/<order_id>", methods=["GET"])
@login_required
def get_order(order_id):
order = db.query(Order).filter_by(id=order_id, user_id=current_user.id).first()
if not order:
return jsonify({"error": "Not found"}), 404
return jsonify(order.to_dict())@app.route("/orders/<order_id>", methods=["GET"])
@login_required
def get_order(order_id):
order = db.query(Order).filter_by(id=order_id, user_id=current_user.id).first()
if not order:
return jsonify({"error": "Not found"}), 404
return jsonify(order.to_dict())That one line never showed up unprompted. Across the snippets in this category, it only appeared when I explicitly told the prompt "users should only be able to access their own orders" — meaning the model had the capability the whole time, it just had no way of inferring the requirement from a normal-sounding prompt that any real developer might type without thinking to spell that out.
2. Error handling that leaks
From a prompt asking for "a payment processing endpoint with error handling":
@app.route("/charge", methods=["POST"])
def charge_card():
try:
result = stripe.Charge.create(amount=request.json["amount"], source=request.json["token"])
return jsonify(result)
except Exception as e:
return jsonify({"error": str(e), "trace": traceback.format_exc()}), 500@app.route("/charge", methods=["POST"])
def charge_card():
try:
result = stripe.Charge.create(amount=request.json["amount"], source=request.json["token"])
return jsonify(result)
except Exception as e:
return jsonify({"error": str(e), "trace": traceback.format_exc()}), 500traceback.format_exc() going straight into a client-facing JSON response is exactly the kind of thing that's genuinely useful while you're debugging locally and genuinely bad in anything resembling production — full stack traces handed to anyone hitting the endpoint, which gives an attacker free reconnaissance about your stack, file paths, and internal structure.
3. Secrets handling
Three separate snippets, across two different tools, when asked to "demonstrate" a working integration, generated something close to this:
stripe.api_key = "sk_test_4eC39HqLyjWDarjtT1zdp7dc"stripe.api_key = "sk_test_4eC39HqLyjWDarjtT1zdp7dc"Obviously a placeholder, formatted exactly like a real key. The problem isn't that the model is being careless — it's that this exact pattern is precisely what's been copy-pasted directly into a .env-less production file by someone moving fast, more times than anyone wants to admit. The model reproduces the "good demo" pattern; it doesn't know your repo is about to go public.
4. Rate limiting and abuse prevention
Across every authentication and password-reset snippet I generated, rate limiting appeared exactly zero times unless I explicitly asked for it in the prompt:
@app.route("/reset-password", methods=["POST"])
def reset_password():
email = request.json["email"]
token = generate_reset_token(email)
send_reset_email(email, token)
return jsonify({"message": "If that email exists, a reset link was sent."}), 200@app.route("/reset-password", methods=["POST"])
def reset_password():
email = request.json["email"]
token = generate_reset_token(email)
send_reset_email(email, token)
return jsonify({"message": "If that email exists, a reset link was sent."}), 200Functionally fine. Also, with no rate limiting attached, a free enumeration and abuse vector for anyone willing to script a few thousand requests at it. Nothing in "write a password reset endpoint" signals that this specific function is a known abuse magnet — that's contextual security knowledge, not something inferable from the function's logic alone.
Why this specific pattern of failure makes sense
Every one of these four categories shares the same root cause: each requires knowing something about deployment context, the threat model, or the broader system that simply isn't visible from an isolated prompt. The model doesn't have your authorization model, your system architecture, or your specific abuse history. It's generating plausible code for the prompt in front of it, and "plausible for this prompt" and "secure for your actual system" are reliably different bars exactly whenever the missing piece is context the prompt never supplied.
What this means for how you actually use it
Treat AI-generated code like a pull request from a competent developer who has never seen your system, your data model, or your threat landscape, because that's a structurally accurate description of what's happening. It earns trust fast on the universally-true patterns (parameterized queries, standard crypto). It needs a specific, deliberate review pass for anything that depends on knowing your system — who's allowed to touch what, what's abuse-prone, what counts as sensitive in your error responses.
The actual skill this puts a premium on
Writing boilerplate faster isn't the valuable skill anymore, the tooling does plenty of that well, as 37 of these 50 snippets prove. The valuable skill is reviewing generated code with a security lens sharp enough to catch the contextual gaps a model structurally cannot have. More code is now being written by something that's good at syntax and blind to your specific system by construction — which makes security-literate code review more important, not less, exactly while a lot of teams are quietly doing less of it because the code "looks done."