Skip to content

6. Data Scopes

A web application serves many users at once, and each user makes numerous requests. Data can therefore be shared at several levels, known as its scope:

  • application scope: the data is shared by all users for the entire lifetime of the server (the list of doctors, for example);
  • request scope: the data exists only for the duration of a single request (a view’s model, the logged-in user as identified for that request);
  • client scope: the data is specific to a user and must persist from one request to the next (the selected language, the logged-in user).

Web applications have long used a server-maintained session for this last scope: a memory specific to each browser, identified via an identification cookie ([JSESSIONID] in [Java], [PHPSESSID] to [PHP]). The session for [Flask] is different: as we saw in Example 20, it is stored in a signed cookie on the client’s side. The server does not store anything between two requests from the same user: it is said to be stateless. This is what makes it possible, for example, to run multiple identical servers behind a load balancer: any one of them can handle any request. The trade-off is that a cookie is under the user’s control; the user can read it and attempt to modify it. Therefore, only non-sensitive data is stored there, and—if the data must be trusted—it is signed by the server, which then detects any modifications: this is the case for the session [Flask] and the authentication token (see the chapter on authentication).

In [Flask], each scope has its own container:

Scope
Support
Lifetime
application
An object created at startup, stored in [app.extensions] and retrieved by [current_app] (example 06)
The entire server lifetime, shared by all requests
Request
The object [g], and the object [request] itself (example 16)
processing a request
session
the object [session], a dictionary stored in a signed cookie
until the browser is closed (by default)
client
a standard cookie set by [set_cookie] (example 22)
the duration set by [max_age]

The objects [current_app], [g], [request], and [session] are imported from the package [flask]. These are proxies (example 05): during a request, each one identifies the object of that request (or of the application processing it).

6.1. Example 23: Scopes

1
2
3
4
5
6
7
8
9
23-scopes/
├── app.py
├── controllers/
│   └── scopes.py
├── services/
│   └── counter.py
└── templates/
    └── scopes/
        └── scopes.html

6.1.1. The objects: services/counter.py

# services/counter.py: a shared counter, and the information of a request
from itertools import count
from threading import Lock


# APPLICATION scope: a single instance (see app.py)
class CounterService:

    def __init__(self):
        self._total = 0
        # several requests can run AT THE SAME TIME: the lock guarantees
        # that two simultaneous increments are not lost
        self._lock = Lock()

    def increment(self) -> int:
        with self._lock:
            self._total += 1
            return self._total


# REQUEST scope: one instance per request (see app.py, start function)
class RequestInfo:

    # a counter shared by all the instances (CLASS attribute);
    # next(...) is atomic: two threads never get the same number
    _instances = count(1)

    def __init__(self, start: str):
        self.number = next(RequestInfo._instances)
        self.start = start
  • Lines 7–18: a visit counter, with application scope: a single object, shared by all requests. As with the task list in Example 20, a lock protects the increment: the [self._total += 1] statement is not atomic (it reads the value, increments it, then writes it), and two threads executing it simultaneously could read the same value—resulting in a visit being lost;
  • lines 22–30: query-scope information: one object per query, containing a number and the time of the query. The number is derived from a counter shared by all instances: [count(1)], from the standard module [itertools], is an iterator that produces 1, 2, 3... each time [next] is called. This is a class attribute (declared at the class level, not in the constructor): it is shared by all instances.

6.1.2. The configuration: app.py

# app.py: the application of example 23
from datetime import datetime
from pathlib import Path

from flask import Blueprint, Flask, g

from controllers.scopes import scopes
from services.counter import CounterService, RequestInfo

BOOTSTRAP = Path(__file__).resolve().parent.parent / "common" / "bootstrap"


def create_app():
    app = Flask(__name__)
    app.jinja_env.trim_blocks = True
    app.jinja_env.lstrip_blocks = True
    # the key that signs the session cookie (SESSION scope)
    app.config["SECRET_KEY"] = "a-secret-key-for-example-23"
    # APPLICATION scope: a single object, created at startup, shared by ALL
    # the requests of ALL the users
    app.extensions["counter"] = CounterService()
    app.register_blueprint(Blueprint("bootstrap", __name__, static_folder=BOOTSTRAP,
                                     static_url_path="/bootstrap"))
    app.register_blueprint(scopes)

    # REQUEST scope: the g object is born and dies with each request. This
    # function, executed BEFORE the action, stores in it an object that the action and the
    # view will find again during THIS request
    @app.before_request
    def start():
        g.info = RequestInfo(datetime.now().strftime("%H:%M:%S.%f")[:-3])

    return app


if __name__ == "__main__":
    create_app().run(port=5000, debug=True)
  • line 18: the key that signs the session cookie;
  • line 21: the application-scope object;
  • lines 29–31: Before each request, a [RequestInfo] object is stored in [g]. The action and the view will retrieve it, but only during this request. [strftime("%H:%M:%S.%f")] formats the time with its microseconds ([%f], six digits); [[:-3]] removes the last three characters from the string, leaving the milliseconds.

6.1.3. The controller: controllers/scopes.py

# controllers/scopes.py: the four scopes of data
from flask import Blueprint, current_app, g, make_response, render_template, request, session

scopes = Blueprint("scopes", __name__)


@scopes.get("/")
def display():
    # APPLICATION: the shared counter
    total = current_app.extensions["counter"].increment()
    # SESSION: a dictionary specific to THIS browser, which Flask stores in a
    # SIGNED cookie (session); it lasts until the browser is closed
    session["visits"] = session.get("visits", 0) + 1
    # CLIENT: a piece of data kept by the BROWSER in an ordinary cookie,
    # sent back to the server with each request
    my_visits = to_integer(request.cookies.get("myVisits")) + 1
    response = make_response(render_template(
        "scopes/scopes.html", total=total, instance=g.info.number,
        session_visits=session["visits"], my_visits=my_visits))
    response.set_cookie("myVisits", str(my_visits), httponly=True, samesite="Lax")
    return response


# "41" -> 41; None or "abc" -> 0
def to_integer(text: str | None) -> int:
    try:
        return int(text)
    except (TypeError, ValueError):
        return 0
  • line 10: the application scope. [current_app] refers to the application processing the current request;
  • line 13: the session scope. [session] is used as a dictionary: [session.get("visits", 0)] returns the value of the key, or 0 if it is absent. When the action modifies the session, [Flask] sends, along with the response, a new cookie, [session], which contains the signed dictionary;
  • line 16: the client scope, with a regular cookie, [myVisits], read from [request.cookies]. Its value is a string provided by the browser and may therefore be missing or invalid; the function [to_integer] returns 0 in either case;
  • Lines 17–20: To set a cookie, you need the response object: [make_response] constructs it from the page generated by the view (example 05), then [set_cookie] adds the header [Set-Cookie] to it. Without [max_age], the cookie lasts until the browser is closed (it is a “session” cookie, as defined by browsers);
  • line 18: the request scope: the object stored in [g] by the function [start].

6.1.4. The view: templates/scopes/scopes.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Scopes</title>
  <link rel="stylesheet" href="{{ url_for('bootstrap.static', filename='css/bootstrap.min.css') }}">
</head>
<body>
  <div class="container py-3">
    <h1 class="h3">The scopes of data</h1>
    {# g and session are accessible in the views, during the SAME request #}
    <ul>
      <li>application: {{ total }} visit(s) to the site, all browsers combined</li>
      <li>request: object no. {{ instance }} (no. {{ g.info.number }} in the view),
        request received at {{ g.info.start }}</li>
      <li>session: {{ session_visits }} visit(s) from this browser ({{ session["visits"] }} in the view)</li>
      <li>client: {{ my_visits }} visit(s) from this browser</li>
    </ul>
  </div>
</body>
</html>

The objects [g] and [session] are also accessible in the views. The view displays the values received from the action, and, in parentheses, the same values read directly from [g] and [session]: during the same request, the action and the view see the same objects.

6.1.5. Execution

After two visits in a browser:

Image

Let's track the requests using [curl], which acts as an initial browser by storing its cookies in the file [a.txt]:

curl -i -c a.txt -b a.txt http://localhost:5000/
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 581
Set-Cookie: myVisits=1; HttpOnly; Path=/; SameSite=Lax
Set-Cookie: session=eyJ2aXNpdHMiOjF9.arfKwA.YK-2GACLdbCJ_NXM5dYJYlqEF-4; HttpOnly; Path=/
...
      <li>application: 1 visit(s) to the site, all browsers combined</li>
      <li>request: object no. 2 (no. 2 in the view),
        request received at 15:38:08.779</li>
      <li>session: 1 visit(s) from this browser (1 in the view)</li>
      <li>client: 1 visit(s) from this browser</li>
...

The response sets two cookies. The first, [myVisits], contains the value in plain text. The second, [session], contains the session dictionary: the portion before the first period is JSON [{"visits":1}], encoded in Base64 (uncompressed, as in Example 20, since it is short); followed by the date and the signature.

The request scope object has the number 2, even though this is the first visit to the page: this is because the [start] function runs before every request received by the application, including requests for which no action exists (in this case, a request for a nonexistent URL, made just before) and requests for static files. In the browser, each page thus triggers a second request for the stylesheet of [Bootstrap]: hence the odd numbers in the screenshot. A second visit, with the same cookies:

curl -i -c a.txt -b a.txt http://localhost:5000/
1
2
3
4
5
6
7
8
Set-Cookie: myVisits=2; HttpOnly; Path=/; SameSite=Lax
Set-Cookie: session=eyJ2aXNpdHMiOjJ9.arfKwA.l68wWEMYpKTfN3xPp_lysXPExO8; HttpOnly; Path=/
...
      <li>application: 2 visit(s) to the site, all browsers combined</li>
      <li>request: object no. 3 (no. 3 in the view),
        request received at 15:38:08.799</li>
      <li>session: 2 visit(s) from this browser (2 in the view)</li>
      <li>client: 2 visit(s) from this browser</li>

A second “browser,” with its own cookie file, initially empty:

curl -c b.txt -b b.txt http://localhost:5000/
1
2
3
4
5
      <li>application: 3 visit(s) to the site, all browsers combined</li>
      <li>request: object no. 4 (no. 4 in the view),
        request received at 15:38:08.813</li>
      <li>session: 1 visit(s) from this browser (1 in the view)</li>
      <li>client: 1 visit(s) from this browser</li>

The application counter has continued running; the session and cookie for the second browser start over at 1. Finally, a malicious user forges their own cookies: 1,000 visits in [myVisits], and a session that purports to contain [{"visits":999}] (Base64-encoded as [eyJ2aXNpdHMiOjk5OX0]), with the signature of another session:

curl -b "myVisits=1000;session=eyJ2aXNpdHMiOjk5OX0.arfKwA.YK-2GACLdbCJ_NXM5dYJYlqEF-4" http://localhost:5000/
1
2
3
4
      <li>application: 4 visit(s) to the site, all browsers combined</li>
      ...
      <li>session: 1 visit(s) from this browser (1 in the view)</li>
      <li>client: 1001 visit(s) from this browser</li>

The regular cookie was accepted as is: 1,001 visits. The forged session, however, was rejected: its signature does not match its content, and [Flask] ignored it, as if it were absent. That is the key difference between a regular cookie and a signed cookie. Let’s remember the rule: data coming from the client is only trustworthy if it is signed by the server; and even when signed, it remains readable: we don’t put anything secret in it.