Sessions & Flash Messages

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Pyramid 2.1 (with pyramid_jinja2 2.10.1, WebTest 3.0.7, WebOb 1.8.11 and pyramid_nacl_session 1.0.0) on Python 3.13, checked on 2026-10-07. Every Python example was executed with webtest.TestApp, and every name was checked against the official narrative chapter on sessions, the pyramid.session and pyramid.interfaces API pages and the installed package source. The running example is the Bookshelf Shelves scenario: a catalogue of public shelves of books where a visitor keeps a few preferences, creates shelves, and is told "Shelf created" exactly once after the redirect that follows the form.

HTTP is stateless: the server forgets a request as soon as it has answered it. A session is the namespace that survives from one request to the next for one visitor. Pyramid defines it as an interface and nothing more. The core does not ship a database of sessions, a session id scheme or a clean-up job. It defines pyramid.interfaces.ISession (a dictionary-like object with a few extra attributes and methods), a hook that turns a session factory into the lazy attribute request.session, and one small implementation of the interface that keeps the whole session inside a signed cookie. Everything else, such as Redis-backed sessions, encrypted cookies or sessions stored in a SQL table, is an add-on or about thirty lines of your own code, and the application code (request.session['key'], request.session.flash()) does not change when the implementation does.

This page covers the interface, the signed-cookie factory and every one of its options, what the signature does and does not protect, the JSON-only rule introduced in Pyramid 2.0, the 4 KB ceiling, the changed() gotcha, invalidate(), expiry, flash messages (a queue of one-shot messages stored in the session), the alternative session factories with their maintenance status, a complete custom server-side factory, and testing. The same topic in the other frameworks of this site is in Sessions, Cookies & Flashing for Flask, Authentication & Signed Cookies for Tornado and Sessions, Cookies, Messages & Signing for Django. How cookies are read and written on the request and the response is in Request & Response, and the settings that carry values such as the session secret are in Settings, Environment & .ini Files.

flowchart LR A[request arrives with a session cookie] --> B{cookie present?} B -- no --> N[empty session, new is True] B -- yes --> V{signature valid and JSON decodes?} V -- no --> N V -- yes --> T{older than timeout?} T -- yes --> E[session emptied] T -- no --> S[session state loaded] N --> W[view reads and writes request.session] E --> W S --> W W --> C{session changed or reissue due?} C -- yes --> R[response callback serializes, signs and sets the cookie] C -- no --> D[no Set-Cookie header] R --> X[response] D --> X

The Session Machinery

Life cycle of a signed-cookie session: the cookie is parsed lazily on first use of request.session and written back by a response callback.

Three pieces cooperate, and none of them is magic.

  • A session factory is any callable that takes a request and returns an object providing ISession. Anything implementing pyramid.interfaces.ISessionFactory fits: a class, a function, or the result of SignedCookieSessionFactory(...).

  • The factory is registered once at configuration time, either with the session_factory argument of the Configurator constructor or with config.set_session_factory(factory).

  • request.session is a lazy attribute: the first access calls the registered factory with the request and caches the result for the rest of the request. A view that never touches it never parses a cookie, and a request without a session never produces a Set-Cookie header.

Without a registered factory, request.session raises an AttributeError rather than a configuration error, so a missing set_session_factory call shows up the first time a view uses a session. The next example registers the signed cookie factory with a secret read from the environment (generated here only so that the example runs on its own) and adds a view that remembers a preferred sort order and counts visits.

import json
import os
import secrets

from pyramid.config import Configurator
from pyramid.response import Response
from pyramid.session import SignedCookieSessionFactory
from webtest import TestApp

# In production the secret comes from the environment or a secret store, is
# identical in every worker process, and is never committed to the repository.
SESSION_SECRET = os.environ.get('SHELVES_SESSION_SECRET') or secrets.token_hex(32)


def preferences(request):
    session = request.session
    session['visits'] = session.get('visits', 0) + 1
    if 'sort' in request.params:
        session['sort'] = request.params['sort']
    body = {'visits': session['visits'], 'sort': session.get('sort', 'title'), 'new': session.new}
    return Response(json_body=body)


def make_app(views=None, session_factory=None, **factory_options):
    """Build a WSGI app; reused by the other examples on this page."""
    if session_factory is None:
        session_factory = SignedCookieSessionFactory(SESSION_SECRET, **factory_options)
    config = Configurator(session_factory=session_factory)
    for name, view in (views or {'preferences': preferences}).items():
        config.add_route(name, '/' + name)
        config.add_view(view, route_name=name)
    return config.make_wsgi_app()


app = TestApp(make_app())
print(app.get('/preferences?sort=author').json)
print(app.get('/preferences').json)
print(app.get('/preferences').json)

# A different browser (a fresh cookie jar) has its own session.
print(TestApp(make_app()).get('/preferences').json)

# Without a session factory request.session does not exist.
bare = Configurator()
bare.add_route('p', '/p')
bare.add_view(preferences, route_name='p')
try:
    TestApp(bare.make_wsgi_app()).get('/p')
except AttributeError as exc:
    print('no factory:', type(exc).__name__)

The first visit reports new: True, the later ones new: False and an incremented counter, and the second browser starts from zero. Two practical notes follow from the lazy design. First, registering two factories in the same configuration unit is reported as a ConfigurationConflictError like any other conflicting declaration, while an included package may still be overridden by the including application (see Configuration & the Configurator). Second, ISession guarantees only the dictionary interface plus created, new, changed(), invalidate(), the three flash methods and the two CSRF-token methods; attributes such as accessed or renewed exist on the signed-cookie implementation but are not part of the interface, so portable code should not use them.

pyramid.session.SignedCookieSessionFactory stores the whole session in one cookie. At the end of a request that changed the session, a response callback serializes the triple (last access time, creation time, data), signs it with an HMAC (SHA-512 by default, keyed with the secret, with a salt that namespaces the key) and puts the result in a Set-Cookie header. On the next request the factory verifies the signature, deserializes the data and checks the timeout. If the signature is wrong or the payload cannot be decoded, the cookie is dropped and the visitor simply gets an empty session; there is no error page for a forged cookie.

The cost of this design is the first thing to understand: the cookie is signed, not encrypted. The signature proves that the content was produced by someone who knows the secret, so a visitor cannot edit their session. It does not hide the content, and the visitor (and anything on the network path without TLS, and any script that can read the cookie) can read the data. The following example reads the cookie like a browser extension would, then tampers with it.

import base64
import hashlib
import json

app = TestApp(make_app())
app.get('/preferences?sort=author')
cookie = app.cookies['session']

# value = urlsafe-base64( HMAC-SHA512 signature (64 bytes) + JSON payload )
raw = base64.urlsafe_b64decode(cookie + '=' * (-len(cookie) % 4))
digest_size = hashlib.sha512().digest_size
signature, payload = raw[:digest_size], raw[digest_size:]
last_access, created, data = json.loads(payload)
print('readable by anyone:', data)

# Editing the payload invalidates the signature, so the session is dropped.
forged = payload.replace(b'author', b'AUTHOR')
forged_cookie = base64.urlsafe_b64encode(signature + forged).decode()
attacker = TestApp(make_app())
attacker.set_cookie('session', forged_cookie)
print('forged cookie accepted?', attacker.get('/preferences').json['visits'] != 1)

# A cookie signed with another secret is rejected the same way.
other = TestApp(make_app(session_factory=SignedCookieSessionFactory(secrets.token_hex(32))))
other.set_cookie('session', cookie)
print('foreign cookie accepted?', other.get('/preferences').json['visits'] != 1)

Consequences for what belongs in the session:

  • Keep it small and non-sensitive: identifiers, preferences, a flash message, a CSRF token. Do not store passwords, personal data or anything whose disclosure would matter. The documentation says to prefer a server-side factory for anything but the most basic applications.

  • Treat the secret like a signing key. Anyone who has it can mint a session with any content, including user_id values, so it must be long (the API page asks for at least 64 characters for SHA-512, which is what secrets.token_hex(32) produces), random, identical in every process and different from every other secret of the application. The security chapter has a warning against reusing one secret for several subsystems (admonishment against secret sharing), and the salt parameter exists for the case where you have to share it anyway.

  • A cross-site scripting hole turns into a session read: with httponly off, injected JavaScript can read the cookie and so the CSRF token and any other value in it. Escape output in templates (see Templates) and set httponly=True.

  • There is no server-side revocation. A signed cookie stays valid until it times out or the secret changes, and the server cannot "log out" a stolen cookie. If you need revocation, use a server-side factory.

  • The factory has no list of fallback secrets. Changing secret immediately invalidates every existing session (all visitors lose their session and the flash messages in it), which is acceptable for a rotation after a leak but not for routine rotation. Graceful rotation needs a custom factory that accepts old signatures while signing with the new one; Flask’s SECRET_KEY_FALLBACKS has no direct Pyramid counterpart.

Every Option of SignedCookieSessionFactory

All parameters except secret have defaults. The table follows the API page for 2.1 and was checked against the function signature in the installed package.

Parameter Default Meaning and advice

secret

required

HMAC key. At least 64 characters for SHA-512, random, shared by all workers, unique per purpose. Read it from the environment or a secret store.

cookie_name

'session'

Name of the cookie. Change it when several applications share a host so that they do not overwrite each other; the __Host- cookie name prefix is a browser feature that also requires secure=True, path='/' and no domain.

max_age

None

Lifetime of the cookie in seconds. None makes it a browser-session cookie that disappears when the browser closes. Set it for "remember me" behaviour. The cookie can expire earlier than timeout, never later in a useful way.

path

'/'

Path attribute of the cookie.

domain

None

Domain attribute. None binds the cookie to the exact host; a value such as a parent domain makes subdomains share (and receive) the session cookie, which widens the attack surface.

secure

False

Adds the Secure flag so the browser only sends the cookie over HTTPS. Set it to True in production. It is False by default only so that local HTTP development works.

httponly

False

Adds HttpOnly, hiding the cookie from JavaScript. Set it to True unless client code genuinely needs the data.

samesite

'Lax'

The SameSite attribute. Lax (default since Pyramid 1.10) keeps the cookie off cross-site subrequests and form posts; 'Strict' is stricter; None (the Python value) omits the attribute entirely. The string 'None' plus secure=True is needed only for embedding scenarios.

timeout

1200

Seconds of inactivity after which the content is discarded. None means no timeout. It applies to the value inside the cookie, so a shorter max_age wins.

reissue_time

0

Seconds that must pass before an access to the session re-issues the cookie (extending the timeout). 0 re-issues on every access; None never re-issues, so the session ends timeout seconds after its last change.

set_on_exception

True

Write the cookie even when the view raised an exception. Set it to False to avoid persisting changes made by a request that failed.

hashalg

'sha512'

HMAC algorithm; anything supported by hashlib.

salt

'pyramid.session.'

Namespace mixed into the key derivation so that one secret can be used for different cookies.

serializer

JSON serializer

Object with loads(bytes) and dumps(obj) that raises ValueError on bad input. The default became pyramid.session.JSONSerializer in Pyramid 2.0.

A production configuration therefore looks like the following. The example prints the cookie header of a response to show which attributes were applied, using a cookie name with the __Host- prefix, a ten-minute timeout and a two-minute re-issue interval (the rule of thumb in the API documentation is a tenth of the timeout).

production = SignedCookieSessionFactory(
    SESSION_SECRET,
    cookie_name='__Host-shelves',
    secure=True,
    httponly=True,
    samesite='Lax',
    timeout=600,
    reissue_time=60,
    max_age=None,
    set_on_exception=False,
)
app = TestApp(make_app(session_factory=production), extra_environ={'wsgi.url_scheme': 'https'})
response = app.get('/preferences?sort=author')
header = response.headers['Set-Cookie']
print(header.split('=', 1)[0])
print(sorted(part.strip() for part in header.split(';')[1:]))

The attributes are HttpOnly, Path=/, SameSite=Lax and secure. Behind a TLS-terminating proxy, make sure the scheme reaches the application (see the proxy section in Request & Response) because browsers, not Pyramid, decide whether to send a Secure cookie, and a development server on plain HTTP simply never receives it back.

Using the Session Object

A session behaves like a dictionary and has a few extra members defined by ISession.

Member Meaning

session['key'], get, pop, update, setdefault, clear, in, iteration

The usual dict interface. Setting or removing a key marks the session as changed automatically.

session.created

Timestamp (seconds since the epoch) of the session’s creation.

session.new

True when the session did not come from a valid, unexpired cookie (see the timeout section for a subtlety).

session.changed()

Tell the session that data was mutated in place, so it must be serialized again.

session.invalidate()

Throw away all data of the session.

session.flash(msg, queue='', allow_duplicate=True), pop_flash(queue=''), peek_flash(queue='')

The flash message API, covered below.

session.new_csrf_token(), session.get_csrf_token()

Token storage used by the session-based CSRF policy; see the CSRF page of this section.

Pyramid’s default CSRF storage policy keeps its token in the session (under a key such as _csrft_), which is one more reason not to use the session key space carelessly: keys starting with an underscore are used by Pyramid itself for CSRF tokens and flash queues. The page on CSRF protection later in this section covers get_csrf_token, check_csrf_token and the cookie-based alternative.

The changed() Gotcha

Setting or deleting a key through the session (session['k'] = v, del session['k'], pop) marks the session as changed. Mutating a value that is already stored, such as appending to a list or editing a nested dictionary, does not: the session cannot see it. In that case you must call session.changed().

The default reissue_time=0 hides this bug in development, because merely reading the session re-issues the cookie and the cookie is serialized from the live (mutated) data at the end of the request. The mutation is persisted by accident, and then silently lost the day somebody sets reissue_time to a larger value or None to stop sending a Set-Cookie on every request. The next example makes the difference visible with reissue_time=None, using a "recently viewed shelves" list.

def view_shelf(request):
    shelf_id = int(request.matchdict['id']) if request.matchdict else int(request.params['id'])
    recent = request.session.get('recent')
    if recent is None:
        recent = request.session['recent'] = []     # assignment marks the session as changed
    recent.append(shelf_id)                         # in-place mutation does not
    if request.params.get('fixed'):
        request.session.changed()
    return Response(json_body=recent)


def read_recent(request):
    return Response(json_body=request.session.get('recent', []))


views = {'view_shelf': view_shelf, 'read_recent': read_recent}

for fixed in ('', '&fixed=1'):
    app = TestApp(make_app(views, reissue_time=None))
    app.get('/view_shelf?id=1' + fixed)      # first call: assigning the new list changed the session
    app.get('/view_shelf?id=2' + fixed)      # later calls only mutate the stored list
    app.get('/view_shelf?id=3' + fixed)
    print('with changed()' if fixed else 'without changed()', app.get('/read_recent').json)

Without changed() the stored list stops at its first element, because only the first call, which assigned the key, marked the session as changed. With changed() all three ids are kept. Calling changed() when it is not needed is harmless, which is why the documentation says to call it whenever in doubt. The same warning is a reason to prefer immutable values (replace the list: session['recent'] = recent + [shelf_id]).

Only JSON Data, and Not More Than About 4 KB

Since Pyramid 2.0 the session contract requires JSON-serializable values and SignedCookieSessionFactory serializes with JSON. Before 2.0 it used pickle, which meant that anyone who obtained the secret (or any bug that let an attacker craft a validly signed cookie) could execute arbitrary code on the server just by sending a cookie. The change was made for that reason, and it is backward incompatible. Practical effects:

  • Values must be strings, numbers, booleans, None, lists and dictionaries with string keys. Tuples come back as lists; dates, Decimal, sets, model instances and bytes are rejected.

  • The error appears when the response is built (the cookie is written in a response callback), not at the line that stored the value, so the traceback points at the framework. Convert on the way in (date.isoformat()), store ids instead of objects, and load the object from the database on demand.

  • The cookie cannot exceed roughly 4 KB. The implementation refuses to write a value longer than 4064 characters and raises ValueError in the same response callback. Base64 and the signature add overhead, so the usable payload is closer to 3 KB. Browsers silently drop oversized cookies, so the limit is also a limit of the whole Cookie header.

  • If you upgraded from an application that used the pickle serializer, old cookies no longer deserialize and are dropped, which logs everybody out once. The 2.0 upgrade notes show a temporary serializer that reads both formats; use it only for a short transition, because unpickling is exactly the risk that was removed.

import datetime

def store_date(request):
    request.session['seen'] = datetime.date(2026, 10, 7)          # not JSON
    return Response('stored')

def store_isoformat(request):
    request.session['seen'] = datetime.date(2026, 10, 7).isoformat()
    request.session['pair'] = (1, 2)                              # a tuple becomes a list
    return Response('stored')

def store_big(request):
    request.session['blob'] = 'x' * 5000
    return Response('stored')

def read_all(request):
    return Response(json_body=dict(request.session))

views = {'store_date': store_date, 'store_isoformat': store_isoformat, 'store_big': store_big, 'read_all': read_all}
app = TestApp(make_app(views))

for name in ('store_date', 'store_big'):
    try:
        app.get('/' + name)
    except (TypeError, ValueError) as exc:
        print(name, '->', type(exc).__name__, '-', str(exc)[:55])

app.get('/store_isoformat')
print(app.get('/read_all').json)

Source: Pyramid: What’s New in Pyramid 2.0 (section "Upgrading Session Serialization")

Invalidating a Session

session.invalidate() throws away every key of the session, flash messages and CSRF token included. Use it:

  • on logout, together with forgetting the identity (see the security pages of this section);

  • on login and on any privilege change, so that data placed in the session before authentication (and the CSRF token that went with it) is not carried into the authenticated session. This is the cookie-based counterpart of the "regenerate the session id on login" rule against session fixation. With a signed cookie there is no id to fixate, but with a server-side factory the factory must issue a new id after invalidate();

  • when a user changes their password, to drop stale state.

The signed-cookie implementation of invalidate() clears the dictionary, which also marks it changed, so the response carries a fresh, empty signed cookie instead of deleting the cookie (a comment in the source says it probably should unset it). The visitor therefore ends up with a valid but empty session. That is enough for correctness, but do not rely on the cookie disappearing from the browser. The example below logs a visitor in, records a flash message while unauthenticated, and shows that invalidating before login keeps the pre-login data out.

from pyramid.httpexceptions import HTTPSeeOther


def anonymous_visit(request):
    request.session['cart_note'] = 'saved before login'
    return Response('ok')


def login(request):
    request.session.invalidate()                 # drop everything stored while anonymous
    request.session['user_id'] = 42              # then record the identity
    return HTTPSeeOther(location=request.route_url('whoami'))


def logout(request):
    request.session.invalidate()
    return HTTPSeeOther(location=request.route_url('whoami'))


def whoami(request):
    return Response(json_body=dict(request.session))


views = {'anonymous_visit': anonymous_visit, 'login': login, 'logout': logout, 'whoami': whoami}
app = TestApp(make_app(views))
app.get('/anonymous_visit')
print('before login:', app.get('/whoami').json)
app.get('/login').follow()
print('after login: ', app.get('/whoami').json)
app.get('/logout').follow()
print('after logout:', app.get('/whoami').json)

Authentication in Pyramid 2.x is normally handled by a security policy, which may or may not use the session as storage (SessionAuthenticationHelper in pyramid.authentication does). The pages on the security policy and on CSRF in this section show how invalidate() fits into login and logout. The deprecated authentication policies of Pyramid 1.x are legacy and are not used here.

Expiry: timeout, max_age and reissue_time

Three settings control how long a signed-cookie session lives, and they act in different places.

  • max_age is the lifetime of the cookie in the browser. None means "until the browser is closed".

  • timeout is checked by the server on every request against the last-access time stored inside the cookie. If the gap is larger than timeout, the data is dropped. A max_age shorter than timeout wins; a longer one adds nothing.

  • reissue_time decides how often the cookie, and with it the "last access" time, is rewritten when the session is only read. The default 0 rewrites on every access (a sliding window and a Set-Cookie on every response that touches the session); a value such as 60 rewrites at most once a minute; None rewrites only when the data changes, giving an absolute lifetime measured from the last change.

The rewriting has a consequence for caching: any response that carries Set-Cookie must not be shared by a cache, so a public page that reads the session with the default reissue_time is effectively uncacheable. Either keep the session out of cacheable views or set reissue_time to None or a larger value.

One subtlety: after a timeout the session is empty, but session.new stays False because the cookie itself was valid (it only expired by age). Do not use new to detect "the visitor’s session has just been reset"; test for the presence of the keys you need. The example drives the clock with unittest.mock to show all three cases without waiting.

from unittest import mock

import pyramid.session


def read_visits(request):
    session = request.session
    session['visits'] = session.get('visits', 0) + 1
    return Response(json_body={'visits': session['visits'], 'new': session.new})


def read_only(request):
    return Response(json_body={'visits': request.session.get('visits', 0)})


views = {'visits': read_visits, 'peek': read_only}


def at(clock, app, path):
    with mock.patch.object(pyramid.session.time, 'time', return_value=clock):
        return app.get(path)


start = 1_800_000_000

# 1. Idle for longer than the timeout: the data is discarded, but new stays False.
app = TestApp(make_app(views, timeout=600))
at(start, app, '/visits')
print('within timeout :', at(start + 300, app, '/visits').json)
print('after timeout  :', at(start + 300 + 601, app, '/visits').json)

# 2. reissue_time=None: reading does not refresh the cookie, so the session ends
#    `timeout` seconds after the last change even though the visitor kept reading.
app = TestApp(make_app(views, timeout=600, reissue_time=None))
at(start, app, '/visits')
at(start + 500, app, '/peek')
print('no read refresh :', at(start + 700, app, '/peek').json)

# 3. The default reissue_time=0: every read slides the window forward.
app = TestApp(make_app(views, timeout=600))
at(start, app, '/visits')
at(start + 500, app, '/peek')
print('sliding window  :', at(start + 700, app, '/peek').json)

# 4. A read-only request with reissue_time=None sends no Set-Cookie header at all.
app = TestApp(make_app(views, reissue_time=None))
at(start, app, '/visits')
print('Set-Cookie on read:', 'Set-Cookie' in at(start + 5, app, '/peek').headers)

Flash Messages

A flash message is a short note that the application stores in the session during one request so that it can be shown during a later one, once. The classic use is the post/redirect/get pattern: a form is submitted with POST, the view changes data, adds a message such as "Shelf created" and answers with a redirect; the following GET page pops the messages from the session and renders them, and a refresh of that page shows nothing. The second use is generic code (a library, a tween, a subscriber) that wants to tell the user something without owning a template.

sequenceDiagram participant B as Browser participant S as Pyramid app B->>S: POST /shelves (name=Poetry) S->>S: create shelf, session.flash("Shelf created", "success") S-->>B: 303 See Other to /shelves, Set-Cookie with the queue B->>S: GET /shelves (cookie sent) S->>S: session.pop_flash("success") empties the queue S-->>B: 200 page with the message, Set-Cookie with the empty queue B->>S: GET /shelves (refresh) S-->>B: 200 page without the message
Post/redirect/get with a flash message: the message travels in the session cookie and is removed when it is popped.

The API is three methods, all on the session:

Method Behaviour

flash(msg, queue='', allow_duplicate=True)

Append msg to the named queue, creating it when needed. msg is stored unchanged (it only has to be JSON-serializable for the signed-cookie factory, so a dictionary such as {"level": "success", "text": "..."} works as well as a string). queue is a string; the empty string is the default queue. With allow_duplicate=False an identical message already in the queue is not added again.

pop_flash(queue='')

Remove the whole queue and return its messages as a list. A second call returns an empty list.

peek_flash(queue='')

Return the messages of the queue without removing them, so the same page can be rendered twice or a decorator can check whether there is anything to show.

Queues are independent. A common convention is one queue per severity (success, info, error), which lets the template group and style them, or one queue per place on the page (a global banner, a form-level message). The queue names are stored in the session under keys that start with an underscore, so a queue is just a list inside the session and counts against the 4 KB budget.

from pyramid.testing import DummyRequest

def flash_demo(request):
    session = request.session
    session.flash('Welcome back')                              # default queue
    session.flash('Shelf created', 'success')
    session.flash('Shelf created', 'success', allow_duplicate=False)   # ignored: already queued
    session.flash('Shelf created', 'success')                  # appended: duplicates are allowed by default
    session.flash({'level': 'warning', 'text': 'Quota almost used'}, 'banner')
    result = {
        'peek success': session.peek_flash('success'),
        'peek again': session.peek_flash('success'),
        'pop success': session.pop_flash('success'),
        'pop success again': session.pop_flash('success'),
        'default queue': session.peek_flash(),
        'banner': session.peek_flash('banner'),
    }
    return Response(json_body=result)


app = TestApp(make_app({'flash_demo': flash_demo}))
for key, value in app.get('/flash_demo').json.items():
    print(f'{key:18}', value)

Both flash and pop_flash count as changes and peek_flash counts as a read, so a request that pops messages writes the (now empty) queue back. Two pitfalls follow.

  • Messages are consumed by the first request that pops them. If the redirect target is not a page (an image, a JSON call, a favicon that hits the same route) or if two tabs race, the message goes to the wrong place or vanishes. Pop only in the view or template that actually renders messages, and do not pop in a global subscriber that runs for every request.

  • Output is not escaped by the session. pop_flash returns exactly what was stored, and a message may include user input (a shelf name). Render it through an auto-escaping template, never concatenate it into HTML.

The complete example below puts the pattern into the Shelves application: a form handler that flashes and redirects, and a list page that pops the queues and renders them with an auto-escaping Jinja2 environment (the same engine the cookiecutter project uses through pyramid_jinja2; see Templates for the integration and the macros that fit flash rendering).

import jinja2
from pyramid.httpexceptions import HTTPSeeOther

env = jinja2.Environment(autoescape=True)
PAGE = env.from_string(
    '{% for level in levels %}'
    '{% for text in messages[level] %}<p class="flash {{ level }}">{{ text }}</p>{% endfor %}'
    '{% endfor %}'
    '<ul>{% for shelf in shelves %}<li>{{ shelf }}</li>{% endfor %}</ul>'
)
SHELVES = ['Fiction']


def list_shelves(request):
    levels = ('success', 'error')
    messages = {level: request.session.pop_flash(level) for level in levels}
    return Response(PAGE.render(levels=levels, messages=messages, shelves=SHELVES), content_type='text/html')


def create_shelf(request):
    name = request.POST.get('name', '').strip()
    if not name:
        request.session.flash('A shelf needs a name', 'error')
    else:
        SHELVES.append(name)
        request.session.flash(f'Shelf {name} created', 'success')
    return HTTPSeeOther(location=request.route_url('list_shelves'))     # always redirect after POST


config = Configurator(session_factory=SignedCookieSessionFactory(SESSION_SECRET, httponly=True))
config.add_route('list_shelves', '/shelves')
config.add_route('create_shelf', '/shelves/new')
config.add_view(list_shelves, route_name='list_shelves', request_method='GET')
config.add_view(create_shelf, route_name='create_shelf', request_method='POST')
app = TestApp(config.make_wsgi_app())

page = app.post('/shelves/new', {'name': 'Poetry'}).follow()
print('after create:', [p for p in page.html.find_all('p')][0].text)
print('refresh     :', [p.text for p in app.get('/shelves').html.find_all('p')])
page = app.post('/shelves/new', {'name': ''}).follow()
print('after error :', [p.attrs['class'] + [p.text] for p in page.html.find_all('p')])
page = app.post('/shelves/new', {'name': '<script>alert(1)</script>'}).follow()
print('escaped     :', '<script>' not in page.text, '&lt;script&gt;' in page.text)

pop_flash is called once per queue and the whole thing is one round trip of the cookie in each direction. In a real page the shelf name would of course be validated and stored in a database (see the data pages of this section); the point here is that the message is shown once, survives the redirect, is separated by queue and is escaped on output. Form validation errors that belong to a field do not need flashing at all: re-render the form with the errors, as in the forms page of this section.

When you need flash messages from an XHR or JSON API, return the message in the response body instead. Flash messages are a feature of the page flow, and a JSON client that does not read cookies would never see them.

Alternative Session Factories

The core factory is deliberately minimal. The official sessions chapter lists three add-ons, and the community has others. Maintenance matters for a security-relevant component, so the table gives the state of each project on PyPI as checked on 2026-10-07 rather than judging by name. "Last release" is the date of the latest file on PyPI.

Package Storage Last release Notes

pyramid_nacl_session

Encrypted cookie (PyNaCl SecretBox)

1.0.0, 2020-08-17 (classified "Mature")

Hides the content from the visitor, but it is still a client-side cookie with the same size limit and no revocation. Its EncryptedCookieSessionFactory defaults to a pickle serializer, so pass serializer=JSONSerializer() explicitly (see the example below). It needs a 32-byte random secret and ships a print_secret script.

pyramid_redis_sessions

Redis, server side

1.0.1, 2015-02-20

Listed in the official chapter but with no release for more than ten years, no declared Python version requirement and no Pyramid 2.x classifier. Treat it as unmaintained and verify it against Pyramid 2.1 yourself before depending on it.

pyramid_session_redis

Redis, server side

1.8.1, 2025-10-27

A separate project on PyPI whose own description calls it a "high performance and actively maintained" server-side session framework for Pyramid and Redis. It is not mentioned in the official chapter and was not tested for this page; read its changelog and test it against 2.1 before adopting.

pyramid_beaker

Beaker backends (files, memory, database, memcached and others)

0.9, 2024-04-05

Listed in the official chapter. It is a thin integration of the Beaker library, so session behaviour and security depend on Beaker’s own maintenance and configuration; review both.

The choice between the options:

  • Signed cookie (the default): no infrastructure, scales across workers because the state travels with the request. Limited to about 4 KB of non-sensitive JSON, and no server-side revocation.

  • Encrypted cookie (pyramid_nacl_session): same operational model, content hidden. Keep the size limit and the lack of revocation in mind and pass a JSON serializer.

  • Server-side store (Redis, a SQL table, Beaker): the cookie holds only a random id, so size and confidentiality are no longer issues and sessions can be revoked, at the price of a shared data store, an expiry strategy and one lookup per request. Use it for anything beyond preferences and flash messages, and for a login that must be revocable.

The first example shows pyramid_nacl_session with the JSON serializer, then reads the cookie as in the earlier example: it is no longer readable. The package is optional and is not installed with Pyramid.

python -m pip install pyramid_nacl_session
from pyramid.session import JSONSerializer
from pyramid_nacl_session import EncryptedCookieSessionFactory

nacl_secret = secrets.token_bytes(32)        # exactly 32 random bytes; share it between workers via config
factory = EncryptedCookieSessionFactory(nacl_secret, serializer=JSONSerializer(), httponly=True)
app = TestApp(make_app(session_factory=factory))
app.get('/preferences?sort=author')
cookie = app.cookies['session']
raw = base64.urlsafe_b64decode(cookie + '=' * (-len(cookie) % 4))
print('author' in raw.decode('latin-1'), len(raw) > 0)         # False: the sort order is not visible
print(app.get('/preferences').json)                          # the application still reads it

Redis itself is covered in Redis. Whatever store you pick, the add-on documents its own settings (usually a session.* prefix in the .ini file, read with the settings mechanism described in Settings, Environment & .ini Files) and the application code keeps using request.session.

Writing Your Own Server-Side Factory

If no add-on fits, you implement ISessionFactory and ISession. The official chapter points to the cookie implementation in pyramid.session as inspiration. The example below is a complete server-side factory with a store interface. The in-memory store is for tests and single-process demos; replace its three methods with Redis (SET with an expiry) or a SQL table in production, and keep JSON as the value format so that sessions stay portable between stores. Design choices worth copying:

  • The cookie contains only a random, unguessable id from secrets.token_urlsafe; it is HttpOnly, SameSite=Lax, and Secure in production.

  • Everything stored is serialized with json.dumps, which keeps the JSON-only contract of Pyramid 2.0 and prevents storing live objects whose later mutation would not be persisted.

  • The session is saved at the end of any request that touched it, which removes the changed() gotcha for nested data at the price of a write per request; if that is too expensive, track changes instead.

  • invalidate() deletes the server-side record and the next save issues a new id, which is what defeats session fixation.

import time

from pyramid.interfaces import ISession
from zope.interface import implementer


class MemorySessionStore:
    """Stand-in for Redis or SQL: get, set and delete JSON strings by id."""

    def __init__(self):
        self._data = {}

    def get(self, sid):
        return self._data.get(sid)

    def set(self, sid, value):
        self._data[sid] = value

    def delete(self, sid):
        self._data.pop(sid, None)


def ServerSideSessionFactory(store, cookie_name='sid', timeout=1200, secure=False):
    @implementer(ISession)
    class ServerSideSession(dict):
        def __init__(self, request):
            super().__init__()
            self.request = request
            self.sid = request.cookies.get(cookie_name)
            self.new = True
            self.created = int(time.time())
            self._invalidated = False
            record = store.get(self.sid) if self.sid else None
            if record is not None:
                saved = json.loads(record)
                if time.time() - saved['accessed'] <= timeout:
                    self.update(saved['data'])
                    self.created = saved['created']
                    self.new = False
                else:
                    store.delete(self.sid)
            if self.new:
                self.sid = None
            request.add_response_callback(self._save)   # runs only if the session was used

        # ISession
        def changed(self):
            pass                                         # everything is saved at the end of the request

        def invalidate(self):
            if self.sid:
                store.delete(self.sid)
            self.sid = None
            self._invalidated = True
            self.clear()

        def flash(self, msg, queue='', allow_duplicate=True):
            queue_list = self.setdefault('_f_' + queue, [])
            if allow_duplicate or msg not in queue_list:
                queue_list.append(msg)

        def pop_flash(self, queue=''):
            return self.pop('_f_' + queue, [])

        def peek_flash(self, queue=''):
            return self.get('_f_' + queue, [])

        def new_csrf_token(self):
            token = secrets.token_hex(20)
            self['_csrft_'] = token
            return token

        def get_csrf_token(self):
            return self.get('_csrft_') or self.new_csrf_token()

        # persistence
        def _save(self, request, response):
            if not self and self.sid is None:
                if self._invalidated:
                    response.delete_cookie(cookie_name)
                return                                   # nothing to store, no cookie
            if self.sid is None:
                self.sid = secrets.token_urlsafe(32)
            record = {'accessed': time.time(), 'created': self.created, 'data': dict(self)}
            store.set(self.sid, json.dumps(record))
            response.set_cookie(cookie_name, self.sid, httponly=True, samesite='Lax', secure=secure)

    return ServerSideSession


store = MemorySessionStore()
app = TestApp(make_app(views={'visits': read_visits, 'peek': read_only, 'login': login, 'whoami': whoami},
                       session_factory=ServerSideSessionFactory(store)))
print(app.get('/visits').json, app.get('/visits').json)
sid = app.cookies['sid']
print('cookie holds only an id:', len(sid), 'characters; server record:', json.loads(store.get(sid))['data'])
app.get('/login').follow()
print('id rotated on login:', app.cookies['sid'] != sid, '| old id still stored:', store.get(sid) is not None)

# Revocation: delete the record on the server and the cookie is worthless.
store.delete(app.cookies['sid'])
print('after revocation:', app.get('/whoami').json)

Two limits of the sketch are deliberate: it never purges abandoned records (give the real store a time-to-live) and it trusts the id alone (rotate on privilege changes and keep ids long and random). The timeout here is a sliding window only if you also refresh the store entry’s expiry on every save, which _save does.

Testing Sessions

Functional tests use webtest.TestApp, which keeps a cookie jar exactly like a browser, so a sequence of requests shares one session, as the examples above relied on. app.reset() clears the jar to simulate a new visitor and app.set_cookie(name, value) plants one. Unit tests of a view that only reads or writes the session do not need an application at all: pyramid.testing.DummyRequest has a session that is a DummySession, a plain dictionary that implements the flash and CSRF methods in memory (without signing, serialization or timeouts).

from pyramid.testing import DummyRequest


def create_shelf_unit(request):
    request.session.flash('Shelf created', 'success')
    return {'name': request.POST['name']}


request = DummyRequest(post={'name': 'Poetry'})
print(create_shelf_unit(request), request.session.peek_flash('success'))
print(type(request.session).__name__)

# Functional test of the cookie jar: reset() makes a new visitor.
app = TestApp(make_app())
app.get('/preferences?sort=author')
print(app.get('/preferences').json['visits'])
app.reset()
print(app.get('/preferences').json['visits'])

A unit test with DummySession cannot catch the JSON-only rule, the size limit or the changed() gotcha, because the dummy session never serializes. Keep at least one functional test per feature that goes through the real factory. The testing page later in this section covers fixtures and the other test helpers.

Pitfalls and Security Checklist

  • The default cookie is signed, not encrypted: never store secrets in it, and use secure=True (TLS) and httponly=True outside local development.

  • Generate the secret with secrets.token_hex(32) (or longer), keep it in the environment or a secret store, share one value between all workers, and never commit it. Rotating it logs every visitor out.

  • Keep the session small, flat and JSON-only. Store identifiers, not objects, and watch the 4 KB ceiling when flash queues grow.

  • Call session.changed() after mutating a stored list or dictionary in place, or replace the value instead.

  • The session cookie is a bearer credential. Combine it with CSRF protection for state-changing requests and keep samesite at Lax or stricter.

  • Escape flash messages on output; they may contain user input.

  • Pop flash queues only where they are rendered, and always redirect after a successful POST.

  • Reading the session with reissue_time=0 sets a cookie on every response and makes pages uncacheable; choose reissue_time deliberately.

  • After a timeout session.new is still False; test for the keys you need instead.

  • Use a server-side factory when you need revocation, bigger data or confidential data, and verify the maintenance state of the add-on first.

  • Authentication state and permissions belong to the security policy of the application, not to ad-hoc keys in the session. For general guidance on session management, see the OWASP Session Management Cheat Sheet and OWASP Cheat Sheet Series pages listed below; this site has no OWASP pages of its own.

What Changed Recently and Where Older Material Is Outdated

Version Change Effect on your code

Pyramid 1.10

samesite option added to the signed-cookie factory, default 'Lax'

Cookies are no longer sent on cross-site subrequests by default; set samesite explicitly if you rely on embedding.

Pyramid 2.0

SignedCookieSessionFactory and the ISession contract use JSON instead of pickle; PickleSerializer is deprecated

Session values must be JSON-serializable. Old pickled cookies are dropped. Tutorials and answers that store arbitrary objects in the session are outdated.

Pyramid 1.9

pyramid.session.check_csrf_token and check_csrf_origin are deprecated aliases of the functions in pyramid.csrf

Import CSRF helpers from pyramid.csrf (the CSRF page of this section).

Pyramid 2.1

No change to the session API; Python 3.6 to 3.9 support dropped, 3.12 to 3.14 added

Everything on this page applies to 2.0 and 2.1.

pyramid_nacl_session 1.0.0

Default serializer is still pickle

Pass serializer=JSONSerializer(); examples written for Pyramid 1.x that omit it are not safe.

Older Pyramid material (including the Pyramid 1.x tutorials) often shows UnencryptedCookieSessionFactoryConfig, an earlier factory that no longer exists in Pyramid 2.1, and explanations that sessions can hold any picklable object. Both are obsolete: use SignedCookieSessionFactory with JSON data. The 1.x authentication and authorization policies are legacy and are replaced by the security policy of Pyramid 2.x, which the security pages of this section describe.

Summary

  • A session is the per-visitor namespace behind request.session. Pyramid defines the ISession interface and the lazy attribute, and registers the implementation with Configurator(session_factory=...) or set_session_factory.

  • SignedCookieSessionFactory(secret, ...) keeps the session in a signed, readable, JSON-only cookie of at most about 4 KB. Set secure=True, httponly=True, a sensible samesite, a timeout, and a reissue_time that matches your caching and expiry needs.

  • Call changed() after in-place mutations, invalidate() on login and logout, and keep the secret long, random and out of the repository.

  • Flash messages are queues of one-shot messages in the session: flash(msg, queue, allow_duplicate), pop_flash and peek_flash, used with post/redirect/get and escaped on output.

  • For revocation, large data or confidential data, use a server-side factory: an add-on after checking its maintenance, or your own ISession implementation as shown above.

  • Test with webtest.TestApp (real cookie jar) and with DummyRequest().session (unit level).