Skip to main content

auth-flask-login

Use when the user asks about "Flask-Login", "Flask login and logout", "Flask user registration", "Flask authentication", "Flask login_required", "Flask password hashing", "Flask session auth", "protect Flask routes", "Flask logout POST CSRF", "Flask login next redirect", or needs the session-based login flow for a Flask 3 application with CSRF protection.

Source facts

Repository
Agents-Store/opencode-plugins
Last source activity
October 3, 2026 at 23:16
Detected SKILL.md language
English
Stars
0
Forks
0

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
auth-flask-login
description
Use when the user asks about "Flask-Login", "Flask login and logout", "Flask user registration", "Flask authentication", "Flask login_required", "Flask password hashing", "Flask session auth", "protect Flask routes", "Flask logout POST CSRF", "Flask login next redirect", or needs the session-based login flow for a Flask 3 application with CSRF protection.
# Flask Authentication with Flask-Login ## Overview Session-based authentication for a Flask 3 app: Flask-Login keeps the signed-in user in the session cookie, Werkzeug hashes the passwords, and Flask-WTF `CSRFProtect` guards every form, including logout. Building blocks: - `LoginManager` created in `extensions.py`, bound with `login_manager.init_app(app)`, with `login_manager.login_view = 'auth.login'` for the redirect of anonymous users (all in `project-scaffold`). - A `user_loader` callback in the factory that returns the user for the id in the session: `db.session.get(User, int(user_id))`. - A `User` model that inherits `UserMixin` and stores only a password hash. The model itself is in `project-scaffold` (minimal) and in the `sqlalchemy-dev` plugin (`model-patterns`, `references/flask-login-user.md`). - Routes `auth.login`, `auth.register`, `auth.logout` in one blueprint: `login_user(user)`, `logout_user()`, `@login_required` (below). - `SECRET_KEY` set and kept stable: it signs the session. `CSRFProtect` initialised: `{{ csrf_token() }}` is undefined in a template without it. Install with the SQLAlchemy pin (see `setup` for why): ```bash pip install Flask Flask-Login Flask-WTF Flask-SQLAlchemy "SQLAlchemy<2.1" ``` ## Routes ```python # routes/auth.py from urllib.parse import urlsplit from flask import Blueprint, flash, redirect, render_template, request, url_for from flask_login import current_user, login_required, login_user, logout_user from sqlalchemy.exc import IntegrityError from werkzeug.security import check_password_hash, generate_password_hash from extensions import db from models import User auth_bp = Blueprint('auth', __name__) NAME_MAX, EMAIL_MAX = 100, 120 # String(100) and String(120) of the User columns MIN_PASSWORD_LENGTH = 15 # NIST SP 800-63B-4: 15 when the password is the only factor, 8 with a second factor MAX_PASSWORD_LENGTH = 128 # NIST asks for at least 64; a cap keeps one request from hashing megabytes # Checked when the email is unknown, so "no such user" and "wrong password" cost the same time. DUMMY_HASH = generate_password_hash('not-a-real-password', method='scrypt') def local_target(target): """Return target only when it is a path on this site; None otherwise (open-redirect guard).""" if not target or not target.startswith('/') or target.startswith('//') or '\\' in target: return None parts = urlsplit(target) return None if parts.scheme or parts.netloc else target def password_problem(password): """Length, not composition: NIST SP 800-63B-4 bans composition rules.""" if len(password) < MIN_PASSWORD_LENGTH: return f'Password must be at least {MIN_PASSWORD_LENGTH} characters' if len(password) > MAX_PASSWORD_LENGTH: return f'Password must be at most {MAX_PASSWORD_LENGTH} characters' return None @auth_bp.route('/register', methods=['GET', 'POST']) def register(): if current_user.is_authenticated: return redirect(url_for('dashboard.dashboard')) if request.method == 'POST': name = request.form.get('name', '').strip() email = request.form.get('email', '').strip().lower() password = request.form.get('password', '') if not name or not email: problem = 'Name and email are required' elif len(name) > NAME_MAX or len(email) > EMAIL_MAX: problem = f'Name must be at most {NAME_MAX} characters and email at most {EMAIL_MAX}' # the column sizes: PostgreSQL raises instead of cutting else: problem = password_problem(password) if problem: flash(problem, 'error') return render_template('register.html') user = User( name=name, email=email, password_hash=generate_password_hash(password, method='scrypt'), ) db.session.add(user) try: db.session.commit() except IntegrityError: # the unique constraint on email decides, so two requests cannot both win db.session.rollback() flash('Email already registered', 'error') return render_template('register.html') login_user(user) flash('Registration successful!', 'success') return redirect(url_for('dashboard.dashboard')) return render_template('register.html') @auth_bp.route('/login', methods=['GET', 'POST']) def login(): if current_user.is_authenticated: return redirect(url_for('dashboard.dashboard')) if request.method == 'POST': email = request.form.get('email', '').strip().lower() password = request.form.get('password', '') user = db.session.scalar(db.select(User).filter_by(email=email)) password_ok = check_password_hash(user.password_hash if user else DUMMY_HASH, password) if user is not None and password_ok: login_user(user) return redirect(local_target(request.args.get('next')) or url_for('dashboard.dashboard')) flash('Wrong email or password', 'error') # one message for both cases: no account enumeration return render_template('login.html') @auth_bp.post('/logout') @login_required def logout(): logout_user() flash('Signed out', 'success') return redirect(url_for('auth.login')) ``` Why it is written this way: - **One error for login.** "You haven't registered yet" for an unknown email tells an attacker which addresses have accounts. Both failures give `Wrong email or password`, and the dummy hash keeps the response time the same. - **Hash method named.** `generate_password_hash(password, method='scrypt')` is the Werkzeug 3 default; naming it fixes the choice in code. The hash is about 160 characters, so `String(256)` is enough. `check_password_hash` reads the method from the stored hash, so old hashes keep working when the method changes. - **Registration on the constraint.** The `unique=True` column is the only check that survives two simultaneous sign-ups; the `IntegrityError` branch turns it into the same flash message. A register form still tells a visitor that an address is taken: if that matters, accept any address and confirm by e-mail. - **`next` stays local.** `@login_required` redirects anonymous users to `/login?next=/path`; the login form posts back to the same URL, so `next` is still there. `local_target` rejects `https://evil.example`, `//evil.example` and `/\evil.example`. - **Passwords: length, not composition.** NIST SP 800-63B-4 asks for at least 15 characters when the password is the only factor (8 with a second factor), at least 64 allowed, and no composition rules ("one digit and one special character" pushes people to `Password1!`). Check new passwords against a list of common and breached ones as well (a local list, or the k-anonymity range API of Have I Been Pwned); that is the rule that actually stops `correct-horse-1!`-style choices, and it needs no change to the routes except one more `elif` in `password_problem`. - **Field sizes.** `name` and `email` are checked against the column sizes before the insert: SQLite stores an over-long value, PostgreSQL raises `DataError` and the visitor sees a 500. - **Logout is `POST` with a CSRF token.** A `GET` logout is triggered by any other page (`<img src="/logout">`). The form is in `base.html` (`project-scaffold`). ## Rate limiting and "remember me" - **Throttle `/login` and `/register`.** Nothing above limits guesses: every request costs the attacker one scrypt check at most. Flask-Limiter (`pip install Flask-Limiter`) adds a per-address limit, for example `@limiter.limit('5 per minute', methods=['POST'])` on both views, with a shared store (Redis) once you run more than one worker. Create the `Limiter` in `extensions.py` like the other extensions. - **Sessions are not persistent here.** `login_user(user)` without `remember=True` signs the user in for the browser session only. If you add a "remember me" box (`login_user(user, remember=True)`), Flask-Login sets a second cookie that outlives the session; harden it like the session cookie: `REMEMBER_COOKIE_SECURE = True`, `REMEMBER_COOKIE_HTTPONLY = True`, `REMEMBER_COOKIE_SAMESITE = 'Lax'` in `ProductionConfig`, and a bounded `REMEMBER_COOKIE_DURATION` (the default is 365 days). ## Templates ```jinja2 {# templates/login.html #} {% extends "base.html" %} {% block title %}Log in{% endblock %} {% block content %} <h1>Log in</h1> <form method="post"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <label>Email <input type="email" name="email" required autofocus></label> <label>Password <span class="password-field"> <input type="password" name="password" id="password" required> <button type="button" data-toggle-password="password">Show</button> </span> </label> <button type="submit">Log in</button> </form> <p><a href="{{ url_for('auth.register') }}">Create an account</a></p> {% endblock %} {% block scripts %}<script src="{{ url_for('static', filename='js/auth.js') }}" defer></script>{% endblock %} ``` The form has no `action`, so it posts to the URL it was loaded from and keeps `?next=`. ```jinja2 {# templates/register.html #} {% extends "base.html" %} {% block title %}Register{% endblock %} {% block content %} <h1>Create an account</h1> <form method="post"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <label>Name <input type="text" name="name" required autofocus maxlength="100"></label> <label>Email <input type="email" name="email" required maxlength="120"></label> <label>Password <span class="password-field"> <input type="password" name="password" id="password" required minlength="15" maxlength="128"> <button type="button" data-toggle-password="password">Show</button> </span> </label> <p class="hint">At least 15 characters. A passphrase of several words is fine; spaces and any characters are allowed.</p> <button type="submit">Register</button> </form> <p><a href="{{ url_for('auth.login') }}">I already have an account</a></p> {% endblock %} {% block scripts %}<script src="{{ url_for('static', filename='js/auth.js') }}" defer></script>{% endblock %} ``` Show / hide password: ```javascript // static/js/auth.js document.querySelectorAll('[data-toggle-password]').forEach(function (button) { button.addEventListener('click', function () { var input = document.getElementById(button.dataset.togglePassword); var show = input.type === 'password'; input.type = show ? 'text' : 'password'; button.textContent = show ? 'Hide' : 'Show'; }); }); ``` The navigation (`current_user.is_authenticated`: Log in / Register, or Dashboard / Log out) is in `templates/base.html` of `project-scaffold`. ## Protecting routes ```python from flask_login import current_user, login_required @clients_bp.route('/clients') @login_required def clients(): # current_user is the signed-in User instance ... ``` - `@login_required` goes under `@route`. The login and register views stay public. - To protect a whole app at once, use the `before_request` hook in `app-patterns` (Request Hooks) with a set of public endpoints. - A route that serves JSON should answer `401`, not redirect to a login page: register `@login_manager.unauthorized_handler` for those blueprints. - Every query for a signed-in user's rows is scoped to `current_user.id`, and a record fetched by an id from the URL is fetched together with its owner. Forgetting it exposes other users' data. The scoped helpers (`owned_by`, `one_or_404`) are in the `sqlalchemy-dev` plugin, `query-patterns`, `references/owner-scoped-queries.md`; the CRUD routes that use them are in `app-patterns`, `references/crud-views.md`. ## Tests With `TestingConfig` (`WTF_CSRF_ENABLED = False`, see `app-patterns`, Testing): ```python # tests/test_auth.py import re import pytest from extensions import db from models import User PASSWORD = 'correct horse battery' # 21 characters, no digit or symbol needed def register(client, email='ann@example.com', password=PASSWORD, name='Ann'): return client.post('/register', data={'name': name, 'email': email, 'password': password}) def test_register_signs_in(client): response = register(client) assert response.status_code == 302 assert client.get('/dashboard').status_code == 200 def test_each_client_has_its_own_user(app): ann, ben = app.test_client(), app.test_client() register(ann, 'ann@example.com', name='Ann') register(ben, 'ben@example.com', name='Ben') assert b'ann@example.com' in ann.get('/dashboard').data assert b'ben@example.com' in ben.get('/dashboard').data assert b'ann@example.com' not in ben.get('/dashboard').data
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub