Marshmallow Serialization & JWT Auth

The previous lesson ended on an honest limitation: hand-building to_dict() methods and checking request.get_json() by hand works, but there's no real validation, and every endpoint anonymous over the internet is wide open. This lesson closes both gaps — Marshmallow for declarative validation and serialization, and JWT tokens for authenticating API requests that don't come from a browser session.

Part 1: The Problem Marshmallow Solves

Recall REST APIs & jsonify's to_dict() method and its one-line if 'title' not in data check. Both are hand-rolled, and both get more fragile the moment the API needs to actually validate types, ranges, or required combinations of fields — is pages really an integer? Is status really one of the three values Bookshelf recognizes? Marshmallow is a Python library built specifically to answer those questions declaratively: define the shape of your data once, as a schema, and get both serialization (model → JSON) and deserialization-with-validation (JSON → validated data) from that single definition.

Installing Flask-Marshmallow

Two packages work together here — the Flask integration layer, and the SQLAlchemy-aware extension that lets a schema read field definitions straight off a model:

pip install flask-marshmallow marshmallow-sqlalchemy

Defining a Schema

A Marshmallow schema for a SQLAlchemy model is genuinely similar in spirit to Django's ModelForm or DRF's ModelSerializer, if either is familiar from this site's Django track — in each case, you point the class at a model, and field definitions are derived automatically instead of retyped by hand.

Example: Initializing Marshmallow and Defining BookSchema

# extensions.py
from flask_marshmallow import Marshmallow

ma = Marshmallow()
# schemas.py
from extensions import ma
from models import Book

class BookSchema(ma.SQLAlchemyAutoSchema):
    class Meta:
        model = Book
        load_instance = True

book_schema = BookSchema()
books_schema = BookSchema(many=True)

ma.SQLAlchemyAutoSchema inspects Book's columns and auto-generates a matching field for each one — title, author, pages, status, and so on — with no manual re-declaration. load_instance = True tells the schema that loading (deserializing) data should produce an actual Book instance, ready to add to the session, rather than a plain dict.

Serializing: dump()

Turning a model instance — or a whole list of them — into JSON-ready data is a single call:

# A single Book
result = book_schema.dump(book)
# {'id': 3, 'title': 'Dune', 'author': 'Frank Herbert', 'pages': 412, 'status': 'reading', ...}

# A list of Books — note many=True was already set on books_schema
books = Book.query.filter_by(status='reading').all()
result = books_schema.dump(books)

book_schema.dump(book) replaces the hand-written to_dict() from the previous lesson entirely — the same output, generated automatically from the model's columns.

Deserializing and Validating: load()

The other direction — turning incoming request JSON into a validated Book — is where Marshmallow earns its keep over a manual request.get_json() check:

from marshmallow import ValidationError

@app.route('/api/books', methods=['POST'])
def create_book():
    try:
        book = book_schema.load(request.get_json())
    except ValidationError as err:
        return jsonify(errors=err.messages), 400

    db.session.add(book)
    db.session.commit()
    return jsonify(book_schema.dump(book)), 201

Send a request with pages set to a non-numeric string, and book_schema.load() raises a ValidationError carrying a clear, per-field message dict — something like {"pages": ["Not a valid integer."]} — rather than silently saving bad data, or a confusing traceback bubbling up from deep inside SQLAlchemy the way a hand-rolled check might allow. This is a genuine, concrete improvement over the previous lesson's single if 'title' not in data check.

Part 2: Why Session Cookies Don't Fit an API Consumer

Authentication with Flask-Login covered Flask's standard session-cookie login flow — a user logs in, Flask sets a signed cookie in their browser, and every later request from that same browser is automatically recognized. That works perfectly for a person clicking around Bookshelf's pages. It works poorly for a mobile app or a separate JavaScript frontend calling this same API: neither one is a browser holding onto Flask's session cookie the way a normal web page load does, and building a login flow around a browser-specific mechanism doesn't transfer cleanly to those consumers.

JWT Auth with Flask-JWT-Extended

Flask-JWT-Extended is the standard package for token-based API authentication in Flask. Instead of a session cookie, a successful login returns a JWT (JSON Web Token) — a signed, self-contained string the client stores itself and sends back with every request that needs authentication.

pip install flask-jwt-extended

Example: A Login Endpoint Issuing a Token

# app.py
from flask_jwt_extended import JWTManager, create_access_token

app.config['JWT_SECRET_KEY'] = os.environ['JWT_SECRET_KEY']
jwt = JWTManager(app)

@app.route('/api/login', methods=['POST'])
def api_login():
    data = request.get_json()
    user = User.query.filter_by(username=data.get('username')).first()

    if user is None or not user.check_password(data.get('password')):
        return jsonify(error='Invalid username or password'), 401

    token = create_access_token(identity=user.id)
    return jsonify(access_token=token)

create_access_token(identity=user.id) builds a signed token carrying the user's id inside it. The client stores that token and includes it on every subsequent request — the server never needs to keep a session for it.

Protecting an Endpoint

A single decorator guards any view that should require a valid token, and get_jwt_identity() reads back whichever identity was embedded when the token was created:

from flask_jwt_extended import jwt_required, get_jwt_identity

@app.route('/api/books', methods=['POST'])
@jwt_required()
def create_book():
    user_id = get_jwt_identity()
    try:
        book = book_schema.load(request.get_json())
    except ValidationError as err:
        return jsonify(errors=err.messages), 400

    book.user_id = user_id
    db.session.add(book)
    db.session.commit()
    return jsonify(book_schema.dump(book)), 201

A request without a valid token hits @jwt_required() and is rejected automatically, before create_book()'s own body ever runs.

Sending the Token from a Client

The client sends the token back on every protected request as an Authorization header, using the Bearer scheme:

curl -X POST https://bookshelf.example.com/api/books \
  -H "Authorization: Bearer <the-token-from-login>" \
  -H "Content-Type: application/json" \
  -d '{"title": "Dune", "author": "Frank Herbert", "pages": 412}'

A Note on Refresh Tokens

One conceptual detail worth knowing about, even without a full worked example here: Flask-JWT-Extended also supports refresh tokens — a second, longer-lived token issued alongside the access token, whose only job is letting a client request a brand-new access token once the original expires, without forcing the user to type their password in and log in again. Access tokens are typically kept short-lived on purpose (limiting the damage if one leaks); refresh tokens are what makes that short lifetime practical without constant re-authentication.

Next up: Testing with Pytest writes automated tests for these exact API endpoints — plus the authenticated views and forms from earlier lessons — so regressions get caught before they reach production.