CodingNic

Authentication and User Security

Creating user accounts

Authentication and User Security 30 min read

Creating user accounts

Creating user accounts

So far in this module we’ve only talked about authentication. In Lesson 1 you built a tiny app that stores a number in the session — proof that the server can remember something about you across requests. But there were no people in that app. Just a counter.

In this lesson we introduce people. We’re going to build a tiny standalone Flask app called auth-playground that does two things:

  1. Lets visitors register an account through a form.
  2. Shows everyone who’s registered, on a public list.

That’s it. No login, no logout, no protected pages — those come in later lessons. We’re focused on one question: how do you create user accounts in a database?

Here’s the important thing about this app: we’ll keep adding to it lesson by lesson. Lesson 4 will add login. Lesson 5 will add logout. Lessons 6 and 7 will add access control and personalisation. By Lesson 7, the auth-playground will be a complete (if minimal) authentication system. Then Lesson 8 will take everything you’ve learned in the playground and apply it to your real Books app from Module 4.

So treat this as your training gym. Small, isolated, easy to throw away and start over. The Books app is the match — the gym is where you learn the moves.


A note on plain-text passwords

We’re going to do something deliberately wrong in this lesson: we’ll store passwords as plain text in the database.

Don’t deploy this anywhere. The very next lesson (Lesson 3) fixes this with proper password hashing. The reason we don’t do it now is that introducing two new concepts at once — database models and hashing — would muddy the lesson. We’re keeping the focus on the model and the form. Hashing gets its own dedicated lesson where it deserves the full spotlight.

You’ll know we’re storing plain text because the /users page will literally show you the passwords in red. The visual is a feature, not a bug. It’s a reminder that this state is temporary and about to be fixed.


Project structure

Create a new folder anywhere outside your Books app:

text
auth-playground/
├── app.py
├── models.py
└── templates/
    ├── register.html
    └── users.html

Four files. Two Python, two HTML. No CSS framework, no extra config. Just plain Flask + Flask-SQLAlchemy.

Install the dependencies:

bash
pip install flask flask-sqlalchemy

The User model

We need somewhere to store users. That means a database table. In SQLAlchemy, a table is described by a Python class — exactly like your Book model from Module 4.

models.py

python
# models.py
from datetime import datetime
from flask_sqlalchemy import SQLAlchemy

db = SQLAlchemy()


class User(db.Model):
    __tablename__ = "users"

    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(80), nullable=False)
    email = db.Column(db.String(120), unique=True, nullable=False)
    password = db.Column(db.String(255), nullable=False)
    created_at = db.Column(db.DateTime, default=datetime.utcnow)

    def __repr__(self):
        return f"<User {self.email}>"

Let’s break down each field. Don’t skim — every choice here matters for the rest of the module.

id

  • Type: Integer
  • Primary key: yes
  • Purpose: The unique number that identifies this user. Every other table that wants to “belong to” a user (like books, or messages, or anything) will store this id as a foreign key.

name

  • Type: String(80)
  • Nullable: False
  • Purpose: The display name we’ll show on dashboards (“Welcome back, Alice”). 80 characters is plenty — most real names are under 50.

email

  • Type: String(120)
  • Unique: True
  • Nullable: False
  • Purpose: Two jobs at once — it’s the user’s contact address, and the value they’ll type in to log in (later). The unique=True is critical: it tells the database to reject any attempt to create two users with the same email. This is what stops someone from registering with an address that’s already in use.

password

  • Type: String(255)
  • Nullable: False
  • Purpose: For now, the user’s password. In Lesson 3 this column won’t hold the password directly — it’ll hold a hash of the password. The String(255) length leaves room for the hash even though plain passwords are usually much shorter. Sizing this generously now saves a migration later.

created_at

  • Type: DateTime
  • Default: datetime.utcnow
  • Purpose: A timestamp of when the account was created. Useful for analytics, “joined Mar 2026” displays, or just for sanity-checking that registration is working.

The Flask app

This is the heart of the playground. One file, two routes for now (/register and /users), plus a small redirect from / so opening localhost:5000 lands somewhere useful.

app.py

python
# app.py
from flask import (
    Flask, render_template, request, redirect,
    url_for, flash
)
from models import db, User

app = Flask(__name__)
app.secret_key = "dev-only-change-me"
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///playground.db"
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False

db.init_app(app)

with app.app_context():
    db.create_all()


@app.route("/")
def index():
    return redirect(url_for("register"))


@app.route("/register", methods=["GET", "POST"])
def register():
    if request.method == "POST":
        name = request.form.get("name", "").strip()
        email = request.form.get("email", "").strip().lower()
        password = request.form.get("password", "")
        confirm = request.form.get("confirm_password", "")

        # Validation
        if not name or not email or not password:
            flash("Please fill in every field.")
            return redirect(url_for("register"))

        if password != confirm:
            flash("Passwords don't match.")
            return redirect(url_for("register"))

        if len(password) < 8:
            flash("Password must be at least 8 characters.")
            return redirect(url_for("register"))

        # Check uniqueness
        existing = User.query.filter_by(email=email).first()
        if existing:
            flash("An account with that email already exists.")
            return redirect(url_for("register"))

        # Create the user
        user = User(
            name=name,
            email=email,
            password=password,  # plain text for now — fixed in Lesson 3
        )
        db.session.add(user)
        db.session.commit()

        flash(f"Account created for {email}.")
        return redirect(url_for("users"))

    return render_template("register.html")


@app.route("/users")
def users():
    all_users = User.query.order_by(User.created_at.desc()).all()
    return render_template("users.html", users=all_users)


if __name__ == "__main__":
    app.run(debug=True)

There’s quite a bit going on here. Let’s walk through the parts that matter.

App setup

python
app.secret_key = "dev-only-change-me"
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///playground.db"

The secret_key is required for flash() to work (flash messages live in the session, and the session needs a key to sign cookies). We touched on this in Lesson 1; we’ll set it up properly in Lesson 4.

The database URI says: “use SQLite, in a file called playground.db.” Flask-SQLAlchemy will create that file the first time it needs to.

Creating the table

python
with app.app_context():
    db.create_all()

This runs once when the app starts. It looks at every model we’ve defined (just User for now) and creates the corresponding tables in the database. If the tables already exist, it does nothing — safe to leave in.

The register handler

The POST branch does six things in order:

  1. Reads the form fields using request.form.get(...) rather than request.form[...] — get returns None (or our default "") instead of crashing if the field is missing.
  2. Normalises the email with .strip().lower(). This means Alice@Example.com and alice@example.com are treated as the same address.
  3. Validates required fields, mismatched passwords, and minimum length.
  4. Checks uniqueness — if the email is already in use, we say so. We’ll improve this message later from a security angle, but for now it’s fine.
  5. Creates the user with User(...) and commits to the database.
  6. Redirects to /users with a success flash. Notice we don’t log them in — that’s a different lesson. We just create the row and show them they exist.

The users handler

python
all_users = User.query.order_by(User.created_at.desc()).all()

Newest first. This is the same SQLAlchemy querying you used in Module 4 — just on a different table.


The templates

Two pages, each with a small <style> block to keep things readable. Plain HTML, no Tailwind, no external CSS.

templates/register.html

html
<!-- templates/register.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Register</title>
  <style>
    body {
      font-family: system-ui, -apple-system, sans-serif;
      max-width: 480px;
      margin: 60px auto;
      padding: 0 20px;
      color: #1f2a26;
    }
    h1 { font-size: 22px; margin-bottom: 0.25rem; }
    p.lead { color: #6b7a72; font-size: 14px; margin-top: 0; }
    .card {
      background: #fff;
      border: 1px solid #e2ddd2;
      border-radius: 12px;
      padding: 24px;
      margin-top: 20px;
    }
    .flash {
      background: #f4e2dc;
      border: 1px solid #e5c5bc;
      color: #1f2a26;
      padding: 10px 14px;
      border-radius: 8px;
      font-size: 13px;
      margin-bottom: 16px;
    }
    label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 4px; }
    input {
      width: 100%;
      box-sizing: border-box;
      padding: 9px 12px;
      border: 1px solid #d8d3c7;
      border-radius: 8px;
      font-size: 14px;
      font-family: inherit;
      margin-bottom: 14px;
    }
    button {
      background: #2f3e37;
      color: #fff;
      border: none;
      padding: 10px 18px;
      border-radius: 8px;
      font-size: 14px;
      font-weight: 500;
      cursor: pointer;
    }
    .nav { font-size: 13px; color: #6b7a72; margin-top: 14px; }
    .nav a { color: #4a6b5c; }
  </style>
</head>
<body>
  <h1>Create an account</h1>
  <p class="lead">A tiny demo of the registration flow.</p>

  {% with messages = get_flashed_messages() %}
    {% for msg in messages %}
      <div class="flash">{{ msg }}</div>
    {% endfor %}
  {% endwith %}

  <div class="card">
    <form action="{{ url_for('register') }}" method="post">
      <label for="name">Name</label>
      <input id="name" name="name" type="text" required>

      <label for="email">Email</label>
      <input id="email" name="email" type="email" required>

      <label for="password">Password</label>
      <input id="password" name="password" type="password" minlength="8" required>

      <label for="confirm_password">Confirm password</label>
      <input id="confirm_password" name="confirm_password" type="password" minlength="8" required>

      <button type="submit">Create account</button>
    </form>
  </div>

  <p class="nav">See who's registered: <a href="{{ url_for('users') }}">All users</a></p>
</body>
</html>

A few details worth pointing out:

  • The get_flashed_messages() block at the top renders any flash messages from the previous request. Both validation errors and the success message after registration appear there.
  • type="email" on the email input lets the browser do basic format checking — though the server should never trust browser validation alone.
  • minlength="8" on the password fields is a soft browser-side hint. The real check is in the route handler.
  • The action goes to /register — the same route that rendered this page. GET shows the form, POST processes it.

templates/users.html

html
<!-- templates/users.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>All users</title>
  <style>
    body {
      font-family: system-ui, -apple-system, sans-serif;
      max-width: 640px;
      margin: 60px auto;
      padding: 0 20px;
      color: #1f2a26;
    }
    h1 { font-size: 22px; margin-bottom: 0.25rem; }
    p.lead { color: #6b7a72; font-size: 14px; margin-top: 0; }
    .flash {
      background: #d6e4dc;
      border: 1px solid #94b4a3;
      color: #1f2a26;
      padding: 10px 14px;
      border-radius: 8px;
      font-size: 13px;
      margin-bottom: 16px;
    }
    table {
      width: 100%;
      border-collapse: collapse;
      margin-top: 16px;
      background: #fff;
      border: 1px solid #e2ddd2;
      border-radius: 12px;
      overflow: hidden;
    }
    th, td {
      text-align: left;
      padding: 10px 14px;
      font-size: 14px;
      border-bottom: 1px solid #e2ddd2;
    }
    th {
      background: #f4f1ea;
      font-weight: 600;
      font-size: 13px;
      color: #6b7a72;
    }
    tr:last-child td { border-bottom: none; }
    .empty { text-align: center; color: #6b7a72; padding: 32px; }
    .password { font-family: monospace; font-size: 12px; color: #a33a2a; }
    .nav { font-size: 13px; color: #6b7a72; margin-top: 14px; }
    .nav a { color: #4a6b5c; }
  </style>
</head>
<body>
  <h1>All users</h1>
  <p class="lead">Everyone registered in the playground database.</p>

  {% with messages = get_flashed_messages() %}
    {% for msg in messages %}
      <div class="flash">{{ msg }}</div>
    {% endfor %}
  {% endwith %}

  {% if users %}
    <table>
      <thead>
        <tr><th>Name</th><th>Email</th><th>Password (stored)</th><th>Joined</th></tr>
      </thead>
      <tbody>
        {% for u in users %}
        <tr>
          <td>{{ u.name }}</td>
          <td>{{ u.email }}</td>
          <td class="password">{{ u.password }}</td>
          <td>{{ u.created_at.strftime("%d %b %Y") }}</td>
        </tr>
        {% endfor %}
      </tbody>
    </table>
  {% else %}
    <div class="empty">No users yet. <a href="{{ url_for('register') }}">Register one</a>.</div>
  {% endif %}

  <p class="nav"><a href="{{ url_for('register') }}">+ Register another user</a></p>
</body>
</html>

This page is mostly a table. Every column maps directly to a field on the User model. The Password (stored) column is rendered in red monospace — a deliberate visual reminder that what’s in the database right now is sensitive plain text.

A real production app would never show passwords on a public page. We’re doing it here because the whole point of this lesson is to see what gets stored, so the next lesson can rip it out and replace it.


Run it

From the project folder:

bash
python app.py

Then open http://127.0.0.1:5000. You’ll be redirected to /register.

Try this sequence:

  1. Register alice@example.com with password sunshine123. You’ll bounce to /users and see Alice’s row appear — name, email, the plain-text password in red, and the join date.
  2. Click “Register another user”. Try registering Alice again with a different password. You’ll get the flash message “An account with that email already exists.” (That’s unique=True doing its job.)
  3. Try registering Bob, but type two different passwords. You’ll get “Passwords don’t match.”
  4. Try registering with a password shorter than 8 characters. You’ll get “Password must be at least 8 characters.”
  5. Register Bob properly with matching passwords. He’ll appear above Alice on the users page (newest first).

If you want to start over with a clean database, just delete instance/playground.db and restart the app.


What you should be staring at

Open /users after registering a couple of accounts. Look at the Password (stored) column. Those are real passwords, in plain text, sitting in your database.

If your laptop got stolen right now, anyone with the SQLite file could see them. If a backup ever leaked, the same. If a contractor took a “test copy” of the database, the same.

This is the problem we’re about to fix in Lesson 3.

But notice that nothing else about the app is wrong. The form works. The validation works. The unique constraint works. The data model is correct. Lesson 3 doesn’t tear anything down — it just changes one line of app.py to make those red passwords disappear, replaced with long unintelligible hashes.

Sit with the discomfort for a moment. Then turn the page.


Common mistakes

  • Forgetting unique=True on email. The app will let you create five accounts with the same email and you won’t notice until login breaks mysteriously (next lesson).
  • Forgetting db.init_app(app) after creating the db object. You’ll get RuntimeError: The current Flask app is not registered with this SQLAlchemy instance.
  • Calling User.query.something() outside an app context. Inside route handlers it works automatically; in a Python shell or one-off scripts you need to wrap it in with app.app_context():.
  • Not normalising email case. If someone registers as Alice@example.com and tries to log in as alice@example.com, they’ll be told their account doesn’t exist. Always lowercase before saving and before looking up.
  • Skipping server-side validation because the HTML required and minlength look like enough. They’re not — anyone can bypass them with curl or by editing the page in dev tools. Server checks are the real defence.

Summary

  • A user is just a database row, modelled with SQLAlchemy like any other entity.
  • The User model needs at minimum: id, name, email (unique), password, created_at.
  • Registration is a POST form that validates input, checks for an existing email, and inserts a new row.
  • The auth-playground is a sandbox we’ll keep extending — login (Lesson 4), logout (Lesson 5), access control (Lesson 6), personalisation (Lesson 7).
  • Passwords are still plain text. That’s a known problem we fix in the next lesson.

Outcome

The auth-playground can now create user accounts. Registration validates input, prevents duplicate emails, and saves rows to a SQLite database. The /users page lets you see exactly what’s stored — and exactly why we need to do something urgent about that password column. Next lesson, we fix it.