From Full Stack Python Security by Dennis Byrne

HTTP sessions are a necessity for all but the most trivial web applications. Web applications use HTTP sessions to isolate the traffic, context, and state of each user. This is the basis for every form of online transaction. If you’re buying something on Amazon, messaging someone on Facebook, or transferring money from your bank, the server must be able to identify you across multiple requests. This illustrates these concepts with Django.

Take 40% off Full Stack Python Security by entering fccbyrne into the discount code box at checkout at

Suppose Alice visits Wikipedia for the first time. Wikipedia doesn’t recognize Alice’s browser so it creates a session. During this process Wikipedia generates and stores an ID for this session. This ID is sent to Alice’s browser in an HTTP response. Alice’s browser holds on to the session ID, sending it back to Wikipedia in all subsequent requests. When Wikipedia receives each request, it uses the inbound session ID to identify the session associated with the request.

Now suppose Wikipedia creates a session for another new visitor, Bob. Like Alice, Bob is assigned a unique session ID. His browser stores his session ID and sends it back with every subsequent request. Wikipedia can now use the session IDs to differentiate between Alice’s traffic and Bob’s traffic. The next figure illustrates this protocol.

Figure 1. A sequence diagram illustrating how wikipedia manages the sessions of two users, Alice and Bob

It’s important that Alice and Bob’s session IDs remain private. If Eve, an eavesdropper, steals a session ID, she can use it to impersonate Alice or Bob. A request from Eve, containing Bob’s hijacked session ID, appears to be no different than a legitimate request from Bob. Many exploits hinge upon stealing, or unauthorized control of, session IDs. This is why session IDs should be sent and received confidentiality over HTTPS rather than HTTP.

You may have noticed some websites use HTTP to communicate with anonymous users and HTTPS to communicate with authenticated users. Malicious network eavesdroppers target these sites by trying to steal the session ID over HTTP, waiting until the user logs in, and hijacking the user’s account over HTTPS. This is known as session sniffing.

Django, like many web application frameworks, prevents session sniffing by changing the session identifier when a user logs in. To be on the safe side, Django does this regardless of whether the protocol was upgraded from HTTP to HTTPS. I recommend an additional layer of defense: use HTTPS for your entire website.

Managing HTTP sessions can be a challenge; this article covers many solutions. Each solution has a different set of security trade-offs, but they all have one thing in common: HTTP cookies.

HTTP cookies

A browser stores and manages small amounts of text known as cookies. A cookie can be created by your browser, but typically it’s created by the server. The server sends the cookie to your browser via a response. The browser echoes back the cookie on subsequent requests to the server.

Websites and browsers communicate session IDs with cookies. When a new user session is created the server sends the session ID to the browser as a cookie. Servers send cookies to browsers with the Set-Cookie response header. The Set-Cookie response header contains a key-value pair representing the name and value of the cookie. By default, a Django session ID is communicated with a cookie named sessionid, shown here in bold font:

 Set-Cookie: sessionid=<cookie-value>

Cookies are echoed back to the server on subsequent requests via the Cookie request header. The Cookie header is a semicolon-delimited list of key-value pairs. Each pair represents a cookie. The following example illustrates a few headers of a request bound for The Cookie header, shown in bold font, contains two cookies.

 Cookie: sessionid=cgqbyjpxaoc5x5mmm9ymcqtsbp7w7cn1; key=value;    #A

#A sending two cookies back to

The Set-Cookie response header accommodates multiple directives. These directives are highly relevant to security when the cookie is a session ID. In this article I cover the following three directives:

  • Secure
  • Domain
  • Max-Age

Secure directive

Servers resist man-in-the-middle attacks by sending the session ID cookie with the Secure directive. An example response header is shown here with a Secure directive in bold font.

 Set-Cookie: sessionid=<session-id-value>; Secure

The Secure directive prohibits the browser from sending the cookie back to the server over HTTP. This ensures the cookie only transmits over HTTPS, preventing a network eavesdropper from intercepting the session ID. For this reason the Secure directive is often confused with the HttpOnly directive, which I cover in chapter 14 of Full Stack Python Security.

In Django, the SESSION_COOKIE_SECURE setting is a boolean value that adds or removes the Secure directive to the session ID Set-Cookie header. It may surprise you to learn this setting defaults to False. This allows new Django applications to immediately support user sessions; it also means the session ID can be intercepted by a man-in-the-middle attack.

WARNING You must ensure SESSION_COOKIE_SECURE is set to True for all production deployments of your system. Django doesn’t do this for you. 

NOTE Changing the settings module You must restart Django before changes to the settings module take effect. To restart Django press CTRL + C in your shell to stop the server, then start it again. 

Domain directive

A server uses the Domain directive to control which hosts the browser should send the session ID to. An example response header is shown here with a Domain directive in bold font.

 Set-Cookie: sessionid=<session-id-value>;

Suppose sends a Set-Cookie header to a browser with no Domain directive. With no Domain directive, the browser echoes back the cookie to, but not to a subdomain such as

Now suppose sends a Set-Cookie header with a Domain directive set to “”. The browser then echoes back the cookie to both and This allows Alice to support HTTP sessions across both systems but it’s less secure. For example, if Mallory hacks, she’s in a better position to compromise because the session IDs from are being handed to her.

The SESSION_COOKIE_DOMAIN setting configures the Domain directive for the session ID Set-Cookie header. This setting accepts two values: None, and a string representing a domain name like “”. This setting defaults to None, omitting the Domain directive from the response header. An example configuration setting is shown here:


#A Configuring the Domain directive from

NOTE The SameSite directive The Domain directive is sometimes      confused with the SameSite directive (which I cover in chapter 16 of Full Stack Python Security.). To avoid this confusion, remember this contrast: the Domain directive relates to where a cookie goes to; the SameSite directive relates to where a cookie comes from. 

Max-Age directive

A server sends the Max-Age directive to declare an expiration time for the cookie. An example response header is shown here with a Max-Age directive in bold font.

 Set-Cookie: sessionid=<session-id-value>; Max-Age=1209600

Once a cookie expires the browser no longer echoes it back to the site it came from. This behavior probably sounds familiar to you. You may have noticed websites like Gmail don’t force you to login every time you return, but if you haven’t been back for a long time, you’re forced to login again. Chances are your cookie and HTTP session expired.

Choosing the best session length for your site boils down to security versus functionality. An extremely long session provides an attacker with an easy target when the browser is unattended. An extremely short session on the other hand forces legitimate users to log back in over and over again.

In Django, the SESSION_COOKIE_AGE setting configures the Max-Age directive for the session ID Set-Cookie header. This setting defaults to 1209600 seconds (two weeks). This value is reasonable for most systems but the appropriate value is site-specific.

Browser-length sessions

If a cookie is set without a Max-Age directive the browser keeps it alive for as long as the tab stays open. This is known as a browser-length session. Browser-length sessions can’t be hijacked by an attacker after a user closes their browser tab. This may seem more secure, but how can you force every user to close every tab when they’re done using a site? Furthermore, the session effectively has no expiry when a user doesn’t close their browser tab. Browser-length sessions increase risk overall and you should generally avoid this feature.

Browser length sessions are configured by the SESSION_EXPIRE_AT_BROWSER_CLOSE setting. Setting this to True removes the Max-Age directive from the session ID Set-Cookie header. Django disables browser-length sessions by default.

Setting cookies programmatically

The response header directives I cover apply to any cookie, not only the session ID. If you’re programmatically setting cookies, you should consider these directives to limit risk. The following code demonstrates how to use these directives when setting a custom cookie in Django.

Listing 1  Programmatically setting a cookie in Django

 from django.http import HttpResponse
 response = HttpResponse()
     secure=True,    #A
     domain='',    #B
     max_age=42, )    #C

#A The browser only sends this cookie over HTTPS

#B and all subdomains receive this cookie

#C After 42 seconds this cookie expires

By now you’ve learned a lot about how servers and HTTP clients use cookies to manage user sessions. At a bare minimum, sessions distinguish traffic between users. In addition to this, sessions serve as a way to manage state for each user. The user’s name, locale, and timezone are common examples of session state. The next section covers how to access and persist session state.

Session state persistence

Like most web frameworks Django models user sessions with an API. This API is accessed via the session object, a property of the request. The session object behaves like a Python dict, storing values by key. Session state is created, read, updated, and deleted through this API; these operations are demonstrated in the next listing.

Listing 2  Django session state access

 request.session['name'] = 'Alice'    #A
 name = request.session.get('name', 'Bob')    #B
 request.session['name'] = 'Charlie'    #C
 del request.session['name']    #D

#A Creating a session state entry

#B Reading a session state entry

#C Updating a session state entry

#D Deleting a session state entry

Django automatically manages session state persistence. Session state is loaded and deserialized from a configurable data source after the request is received. If the session state is modified during the request lifecycle, Django serializes and persists the modifications when the response is sent. The abstraction layer for serialization and deserialization is known as the session serializer.

The session serializer

Django delegates the serialization and deserialization of session state to a configurable component. This component is configured by the SESSION_SERIALIZER setting. Django natively supports two session serializer components:

  • JSONSerializer, the default session serializer
  • PickleSerializer

The JSONSerializer transforms session state to and from JSON. This approach allows you to compose session state with basic Python data types such as integers, strings, dicts, and lists. The following code uses a JSONSerializer to serialize and deserialize a dict, shown in bold font.

 >>> from django.contrib.sessions.serializers import JSONSerializer
 >>> json_serializer = JSONSerializer()
 >>> serialized = json_serializer.dumps({'name': 'Bob'})    #A
 >>> serialized
 b'{"name":"Bob"}'    #B
 >>> json_serializer.loads(serialized)    #C
 {'name': 'Bob'}    #D

#A serializing a Python dict

#B serialized JSON

#C deserializing JSON

#D deserialized Python dict

PickleSerializer transforms session state to and from byte streams. As the name implies, PickleSerializer is a wrapper for the Python pickle module. This approach allows you to store arbitrary Python objects in addition to basic Python data types. An application defined Python object, defined and created in bold font, is serialized and deserialized by the following code.

 >>> from django.contrib.sessions.serializers import PickleSerializer
 >>> class Profile:
 ...     def __init__(self, name):
 ... = name
 >>> pickle_serializer = PickleSerializer()
 >>> serialized = pickle_serializer.dumps(Profile('Bob'))    #A
 >>> serialized
 b'\x80\x05\x95)\x00\x00\x00\x00\x00\x00\x00\x8c\x08__main__...'    #B
 >>> deserialized = pickle_serializer.loads(serialized)    #C
 >>>    #D

#A serializing an application defined object

#B serialized byte stream

#C deserializing byte stream

#D deserialized object

The trade-off between JSONSerializer and PickleSerializer is security versus functionality. JSONSerializer is safe, but it can’t serialize arbitrary Python objects. The PickleSerializer performs this functionality but it comes with a severe risk. The pickle module documentation gives us the following warning (

  “The pickle module is not secure. Only unpickle data you trust. It is possible to construct malicious pickle data which will execute arbitrary code during unpickling. Never unpickle data that could have come from an untrusted source, or that could have been tampered with.”

The PickleSerializer can be horrifically abused if an attacker is able to modify the session state. I cover this form of attack later in this article; stay tuned.

Django automatically persists serialized session state with a session engine. The session engine is a configurable abstraction layer for the underlying data source. Django ships with five different options, each with its own set of strengths and weaknesses, listed here:

  • Simple cache-based sessions
  • Write-through cache-based sessions
  • Database-based sessions, the default option
  • File-based sessions
  • Signed-cookie sessions

Simple cache-based sessions

Simple cache-based sessions allow you to store session state in a cache service such as Memcached or Redis. Cache services store data in memory rather than on disk. This means you can store and load data from these services quickly, but occasionally the data can be lost. For example, if a cache service runs out of free space it writes new data over the least recently accessed old data. If a cache service is restarted then all data is lost.

The greatest strength of a cache service, speed, complements the typical access pattern for session state. Session state is read frequently (on every request). By storing session state in memory, an entire site can reduce latency and increase throughput and provide a better user experience.

The greatest weakness of a cache service, data loss, doesn’t apply to session state to the same degree as other user data. In the worst case scenario, the user must log back into the site, recreating the session. This is undesirable but calling it “data loss” is a stretch. Session state is therefore expendable and the downside is limited.

The most popular and fastest way to store Django session state is to combine a simple cache-based session engine with a cache service like Memcached. In the settings module, assigning SESSION_ENGINE to “django.contrib.sessions.backends.cache” configures Django for simple cache-based sessions. Django natively supports two different Memcached cache backend types.

Memcached Backends

MemcachedCache and PyLibMCCache are the fastest and most commonly used cache backends. The CACHES setting configures cache service integration. This setting is a dict, representing a collection of individual cache backends. The next listing illustrates two ways to configure Django for Memcached integration. The MemcachedCache option is configured to use a local loopback address; the PyLibMCCache option is configured to use a unix socket.

Listing 3 Caching with Memcached

     'default': {
         'BACKEND': 'django.core.cache.backends.memcached.MemcachedCache',
         'LOCATION': '',    #A
     'cache': {
         'BACKEND': 'django.core.cache.backends.memcached.PyLibMCCache',
         'LOCATION': '/tmp/memcached.sock',    #B

#A Local loopback address

#B Unix socket address

Local loopback addresses and unix sockets are secure because traffic to these addresses doesn’t leave the machine. At the time of this writing TLS functionality is unfortunately described as “experimental” on the Memcached wiki.

Django supports four additional cache backends. These options are either less      popular, insecure, or both, and I cover them here briefly.

  • Database backend
  • Local memory backend, the default option
  • Dummy backend
  • File system backend

Database backend

The DatabaseCache option configures Django to use your database as a cache backend. Using this option gives you one more reason to send your database traffic over TLS. Without a TLS connection everything you cache, including session IDs, is accessible to a network eavesdropper. The next listing illustrates how to configure Django to cache with a database backend.

Listing 4 Caching with a database

     'default': {
         'BACKEND': 'django.core.cache.backends.db.DatabaseCache',
         'LOCATION': 'database_table_name',

The major trade-off between a cache service and a database is performance versus storage capacity. Your database can’t perform as well as a cache service. A database persists data to disk; a cache service persists data to memory. On the other hand, your cache service is never able to store as much data as a database. This option is valuable in rare situations where the session state isn’t expendable.

Local memory, dummy, and file system backends

LocMemCache caches data in local memory where only a ridiculously well positioned attacker could access it. DummyCache is the only thing more secure than LocMemCache because it doesn’t store anything. These options, illustrated by the following listing, are secure but neither of them are useful beyond development or testing environments. Django uses LocMemCache by default.

Listing 5 Caching with local memory, or nothing at all

     'default': {
         'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
     'dummy': {
         'BACKEND': 'django.core.cache.backends.dummy.DummyCache',

FileBasedCache, as you may have guessed, is unpopular and insecure. FileBasedCache users don’t have to worry if their unencrypted data is sent over the network – it’s written to the file system instead.

Listing 6 Caching with the file system

     'default': {
         'BACKEND': 'django.core.cache.backends.filebased.FileBasedCache',
         'LOCATION': '/var/tmp/file_based_cache',

Write-through cache-based sessions

Write-through cache-based sessions allow you to combine a cache service and a database to manage session state. Under this approach, when Django writes session state to the cache service, the operation also “writes through” to the database. This means the session state is persistent, at the expense of write performance. When Django needs to read session state, it reads from the cache service first, using the database as a last resort. This means you’ll take an occasional performance hit on read operations as well.

Setting the SESSION_ENGINE setting to “django.contrib.sessions.backends.cache_db” enables write-through cache-based sessions.

Database-based session engine

Database-based sessions bypass Django’s cache integration entirely. This option is useful if you’ve chosen to forgo the overhead of integrating your application with a cache service. Database-based sessions are configured by setting SESSION_ENGINE to “django.contrib.sessions.backends.db”. This is the default behavior.

Django doesn’t automatically clean up abandoned session state. Systems using persistent sessions need to ensure the clearsessions subcommand is invoked at regular intervals. This helps you reduce storage costs, but more importantly, it helps you reduce the size of your attack surface if you store sensitive data in the session. The following command, executed from the project root directory, demonstrates how to invoke the clearsessions subcommand.

 $ python clearsessions

File-based session engine

As you may have guessed this option is incredibly insecure. Each file-backed session is serialized to a single file. The session ID is in the file name and session state is stored unencrypted. Anyone with read access to the filesystem can hijack a session or view session state. Setting SESSION_ENGINE to “django.contrib.sessions.backends.file” configures Django to store session state in the file system.

Cookie-based session engine

A cookie-based session engine stores session state in the session ID cookie itself. With this option the session ID cookie doesn’t only identify the session, it i     s the session. Instead of storing the session locally, Django serializes and sends the whole thing to the browser. Django then deserializes the payload when the browser echoes it back on subsequent requests.

Before sending the session state to the browser, the cookie-based session engine hashes the session state with an HMAC function. The hash value obtained from the HMAC function is paired with the session state; Django sends them to the browser together as the session ID cookie.

When the browser echoes back the session ID cookie, Django extracts the hash value and authenticates the session state. Django does this by hashing the inbound session state and comparing the new hash value to the old hash value. If the hash values don’t match, Django knows the session state has been tampered with and the request is rejected. If the hash values match, Django trusts the session state. The round trip process is illustrated by the next figure.

Figure 2. Django hashes what it sends and authenticates what it receives

Every HMAC function requires a key. Where does Django get the secret key? From the settings module.

The SECRET_KEY setting

Every generated Django application contains a SECRET_KEY setting in the settings module. This setting is important. Contrary to popular belief Django doesn’t use the SECRET_KEY to encrypt data. Instead, Django uses this parameter to perform keyed hashing. The value of this setting defaults to a unique random string. It’s fine to use this value in your development or test environments, but in your production environment it’s important to retrieve a different value from a location which is more secure than your code repository.

WARNING The production value for SECRET_KEY should maintain three properties. The value should be unique, random, and sufficiently long. Fifty characters, the length of the generated default value, is sufficiently long. Do not set SECRET_KEY to a password or a passphrase; nobody should need to remember it. If someone can remember this value, the system is less secure. 

At first glance the cookie-based session engine may seem like a decent option. Django uses an HMAC function to authenticate and verify the integrity of the session state for every request. Unfortunately, this option has many downsides, some of which are highly risky:

  • Cookie size limitations
  • Unauthorized access to session state
  • Replay attacks
  • Remote code execution attacks

Cookie size limitations

File systems and databases are meant to store large amounts of data; cookies aren’t. RFC 6265 requires HTTP clients to support “at least 4096 bytes per cookie” ( HTTP clients are free to support cookies larger than this, but they aren’t obligated to. For this reason, a serialized cookie-based Django session should remain below four kilobytes in size.

Unauthorized access to session state

The cookie-based session engine hashes the outbound session state; it doesn’t encrypt the session state. This guarantees integrity but it doesn’t guarantee confidentiality. The session state is therefore readily available to a malicious user via the browser. This renders the system vulnerable if the session contains information the user shouldn’t have access to.

Suppose Alice and Eve are both users of, a social media site. Alice is angry at Eve for executing a man-in-the-middle attack, and she blocks her. Like other social media sites, doesn’t notify Eve she has been blocked. Unlike other social media sites, stores this information in cookie-based session state.

Eve uses the following code to see who has blocked her. First, she programmatically authenticates with the requests package. Next, she extracts, decodes and deserializes her own session state from the session ID cookie. The deserialized session state reveals Alice has blocked Eve (in bold font).

 >>> import base64
 >>> import json
 >>> import requests
 >>> credentials = {
 ...     'username': 'eve',
 ...     'password': 'evil', }
 >>> response =    #A
 ...     '',    #A
 ...     data=credentials, )    #A
 >>> sessionid = response.cookies['sessionid']    #B
 >>> decoded = base64.b64decode(sessionid.split(':')[0])    #B
 >>> json.loads(decoded)    #B
 {'name': 'Eve', 'username': 'eve', 'blocked_by': ['alice']}    #C

#A Eve logs in to Bob’s social media site

#B Eve extracts, decodes, and deserializes the session state

#C Eve sees Alice has blocked her

Replay attacks

The cookie-based session engine uses an HMAC function to authenticate the inbound session state. This tells the server who is the original author of the payload. This can’t tell the server if the payload it receives is the latest version of the payload. The browser can’t get away with modifying the session ID cookie, but the browser can replay an older version of it. An attacker may exploit this limitation with a replay attack.

Suppose is configured with a cookie-based session engine. The site gives a one-time discount to each new user. A boolean in the session state represents the user’s discount eligibility. Mallory, a malicious user, visits the site for the first time. As a new user she is eligible for a discount and her session state reflects this. She saves a local copy of her session state. She then makes her first purchase, receives a discount, and the site updates her session state as the payment is captured. She is no longer eligible for a discount. Later, Mallory replays her session state copy on subsequent purchase requests to obtain additional unauthorized discounts. Mallory has successfully executed a replay attack.

A replay attack is any exploit used to undermine a system with the repetition of valid input in an invalid context. Any system is vulnerable to a replay attack if it can’t distinguish between replayed input and ordinary input. Distinguishing replayed input from ordinary input is difficult because at one point in time, replayed input was ordinary input.

These attacks aren’t confined to ecommerce systems. Replay attacks have been used to forge automated teller machine (ATM) transactions, unlock vehicles, open garage doors, and bypass voice recognition authentication.

Remote code-execution attacks

Combining cookie-based sessions with PickleSerializer is a slippery slope. This combination of configuration settings can be severely exploited by an attacker if they have access to the SECRET_KEY setting.

WARNING Remote code execution attacks are brutal. Never combine cookie-based sessions with PickleSerializer; the risk is too great. This combination is unpopular for good reasons. 

Suppose serializes cookie-based sessions with PickleSerializer. Mallory, a disgruntled ex-employee of, remembers the SECRET_KEY. She executes an attack on with the following plan:

  1. Write malicious code
  2. Hash the malicious code with an HMAC function and the SECRET_KEY
  3. Send the malicious code and hash value to as a session cookie
  4. Sit back and watch as executes Mallory’s malicious code

First, Mallory writes malicious Python code. Her goal is to trick into executing this code. She installs Django, creates a PickleSerializer, and serializes the malicious code to a binary format.

Next, Mallory hashes the serialized malicious code. She does this the same way the server hashes session state, using an HMAC function and the SECRET_KEY. Mallory now has a valid hash value of the malicious code.

Finally, Mallory pairs the serialized malicious code with the hash value, disguising them as cookie-based session state. She sends the payload to as a session cookie in a request header. Unfortunately, the server successfully authenticates the cookie; the malicious code, after all, was hashed with the same SECRET_KEY the server uses. After authenticating the cookie, the server deserializes the session state with PickleSerializer, inadvertently executing the malicious script. Mallory has successfully carried out a remote code execution attack. Figure 3 illustrates Mallory’s attack.

Figure 3. Mallory uses a compromised SECRET_KEY to execute a remote code execution attack

The following example demonstrates how Mallory carries out her remote code execution attack from an interactive Django shell. In this attack, Mallory tricks into killing itself by calling the sys.exit function. Mallory places a call to sys.exit in a method the PickleSerializer calls as it deserializes her code. Mallory uses Django’s signing module to serialize and hash the malicious code, like a cookie-based session engine. Finally, she sends the request using the requests package. No response to the request emerges, and the recipient dies (in bold font).

 $ python shell
 >>> import sys
 >>> from django.contrib.sessions.serializers import PickleSerializer
 >>> from django.core import signing
 >>> import requests
 >>> class MaliciousCode:
 ...     def __reduce__(self):    #A
 ...         return sys.exit, ()    #B
 >>> session_state = {'malicious_code': MaliciousCode(), }
 >>> sessionid = signing.dumps(    #C
 ...     session_state,    #C
 ...     salt='django.contrib.sessions.backends.signed_cookies',    #C
 ...     serializer=PickleSerializer)    #C
 >>> session = requests.Session()
 >>> session.cookies['sessionid'] = sessionid
 >>> session.get('')    #D
 Starting new HTTPS connection (1):
 http.client.RemoteDisconnected: Remote end closed connection without response    #E

#A Pickle calls this method as it deserializes

#B Django kills itself with this line of code

#C Django’s signing module serializes and hashes Mallory’s malicious code

#D Sending the request

#E Receiving no response

Setting SESSION_ENGINE to “django.contrib.sessions.backends.signed_cookies” configures Django to use a cookie-based session engine.


  • Servers set session IDs on browsers with the Set-Cookie response header
  • Browsers send session IDs to servers with the Cookie request header
  • Use the Secure, Domain, and Max-Age directives to resist online attacks
  • Django natively supports five different ways to store session state
  • Django natively supports six different ways to cache data
  • Replay attacks can abuse cookie-based sessions
  • Remote code execution attacks can abuse pickle serialization
  • Django uses the SECRET_KEY setting for keyed hashing, not encryption

If you want to see more, check out the book on Manning’s liveBook platform here.