Cookieless Sessionless Phoenix HTML Caching

Cookieless Sessionless Phoenix HTML Caching

In this post, I explore creating a new cookieless pipeline for the Phoenix HTML.

I want to be able to cache HTML for Varnish or Cloudflare. Varnish (and Cloudflare) wont cache HTML (or other assets) if they have cookies. Varnish is designed to have conservative defaults, and caching cookies is VERY BAD, its a security and privacy risk, as cookies are the primary method for the authentication and personalization, think shopping cart, online.

Version check

Install latest Phoenix on www.Ubuntu, using the Installation official docs.

I am running OK versions and don't need to upgrade.

niccolox@niccolox-xps:~/Projects/Devekko$ mix www.hex
Found existing entry: /home/niccolox/.asdf/installs/elixir/1.13.3-otp-24/.mix/archives/hex-1.0.1
Are you sure you want to replace it with "https://repo.hex.pm/installs/1.13.0/hex-1.0.1.ez"? [Yn] n
niccolox@niccolox-xps:~/Projects/Devekko$ elixir -v
Erlang/OTP 24 [erts-12.3.1] [source] [64-bit] [smp:16:16] [ds:16:16:10] [async-threads:1] [jit]

Elixir 1.13.3 (compiled with Erlang/OTP 24)
niccolox@niccolox-xps:~/Projects/Devekko$ erl -v
Erlang/OTP 24 [erts-12.3.1] [source] [64-bit] [smp:16:16] [ds:16:16:10] [async-threads:1] [jit]

Eshell V12.3.1  (abort with ^G)
1> 
BREAK: (a)bort (A)bort with dump (c)ontinue (p)roc info (i)nfo
       (l)oaded (v)ersion (k)ill (D)b-tables (d)istribution

Install Latest Phoenix Generator

niccolox@niccolox-xps:~/Projects/Devekko$ mix archive.install hex phx_new
Resolving Hex dependencies...
Dependency resolution completed:
New:
  phx_new 1.6.12
* Getting phx_new (Hex package)
All dependencies are up to date
Compiling 11 files (.ex)
Generated phx_new app
Generated archive "phx_new-1.6.12.ez" with MIX_ENV=prod
Are you sure you want to install "phx_new-1.6.12.ez"? [Yn] Y
* creating /home/niccolox/.asdf/installs/elixir/1.13.3-otp-24/.mix/archives/phx_new-1.6.12

Create Phoenix App

I am creating a Phoenix app to share on Github for peer review.

niccolox@niccolox-xps:~/Projects/Devekko$ mix archive.install hex phx_new
Resolving Hex dependencies...
Dependency resolution completed:
New:
  phx_new 1.6.12
* Getting phx_new (Hex package)
All dependencies are up to date
Compiling 11 files (.ex)
Generated phx_new app
Generated archive "phx_new-1.6.12.ez" with MIX_ENV=prod
Are you sure you want to install "phx_new-1.6.12.ez"? [Yn] Y
* creating /home/niccolox/.asdf/installs/elixir/1.13.3-otp-24/.mix/archives/phx_new-1.6.12
niccolox@niccolox-xps:~/Projects/Devekko$ mix phx.new phx_html_cookieless
* creating phx_html_cookieless/config/config.exs
* creating phx_html_cookieless/config/dev.exs
* creating phx_html_cookieless/config/prod.exs
* creating phx_html_cookieless/config/runtime.exs
* creating phx_html_cookieless/config/test.exs
* creating phx_html_cookieless/lib/phx_html_cookieless/application.ex
* creating phx_html_cookieless/lib/phx_html_cookieless.ex
* creating phx_html_cookieless/lib/phx_html_cookieless_web/views/error_helpers.ex
* creating phx_html_cookieless/lib/phx_html_cookieless_web/views/error_view.ex
* creating phx_html_cookieless/lib/phx_html_cookieless_web/endpoint.ex
* creating phx_html_cookieless/lib/phx_html_cookieless_web/router.ex
* creating phx_html_cookieless/lib/phx_html_cookieless_web/telemetry.ex
* creating phx_html_cookieless/lib/phx_html_cookieless_web.ex
* creating phx_html_cookieless/mix.exs
* creating phx_html_cookieless/README.md
* creating phx_html_cookieless/.formatter.exs
* creating phx_html_cookieless/.gitignore
* creating phx_html_cookieless/test/support/conn_case.ex
* creating phx_html_cookieless/test/test_helper.exs
* creating phx_html_cookieless/test/phx_html_cookieless_web/views/error_view_test.exs
* creating phx_html_cookieless/lib/phx_html_cookieless/repo.ex
* creating phx_html_cookieless/priv/repo/migrations/.formatter.exs
* creating phx_html_cookieless/priv/repo/seeds.exs
* creating phx_html_cookieless/test/support/data_case.ex
* creating phx_html_cookieless/lib/phx_html_cookieless_web/controllers/page_controller.ex
* creating phx_html_cookieless/lib/phx_html_cookieless_web/views/page_view.ex
* creating phx_html_cookieless/test/phx_html_cookieless_web/controllers/page_controller_test.exs
* creating phx_html_cookieless/test/phx_html_cookieless_web/views/page_view_test.exs
* creating phx_html_cookieless/assets/vendor/topbar.js
* creating phx_html_cookieless/lib/phx_html_cookieless_web/templates/layout/root.html.heex
* creating phx_html_cookieless/lib/phx_html_cookieless_web/templates/layout/app.html.heex
* creating phx_html_cookieless/lib/phx_html_cookieless_web/templates/layout/live.html.heex
* creating phx_html_cookieless/lib/phx_html_cookieless_web/views/layout_view.ex
* creating phx_html_cookieless/lib/phx_html_cookieless_web/templates/page/index.html.heex
* creating phx_html_cookieless/test/phx_html_cookieless_web/views/layout_view_test.exs
* creating phx_html_cookieless/lib/phx_html_cookieless/mailer.ex
* creating phx_html_cookieless/lib/phx_html_cookieless_web/gettext.ex
* creating phx_html_cookieless/priv/gettext/en/LC_MESSAGES/errors.po
* creating phx_html_cookieless/priv/gettext/errors.pot
* creating phx_html_cookieless/assets/css/phoenix.css
* creating phx_html_cookieless/assets/css/app.css
* creating phx_html_cookieless/assets/js/app.js
* creating phx_html_cookieless/priv/static/robots.txt
* creating phx_html_cookieless/priv/static/images/phoenix.png
* creating phx_html_cookieless/priv/static/favicon.ico

Fetch and install dependencies? [Yn] Y
* running mix deps.get
* running mix deps.compile

Create the Postgres DB

niccolox@niccolox-xps:~/Projects/Devekko$ cd phx_html_cookieless
niccolox@niccolox-xps:~/Projects/Devekko/phx_html_cookieless$ mix ecto.create
warning: the :gettext compiler is no longer required in your mix.exs.

Please find the following line in your mix.exs and remove the :gettext entry:

    compilers: [..., :gettext, ...] ++ Mix.compilers(),

  (gettext 0.20.0) lib/mix/tasks/compile.gettext.ex:5: Mix.Tasks.Compile.Gettext.run/1
  (mix 1.13.3) lib/mix/task.ex:397: anonymous fn/3 in Mix.Task.run_task/3
  (mix 1.13.3) lib/mix/tasks/compile.all.ex:92: Mix.Tasks.Compile.All.run_compiler/2
  (mix 1.13.3) lib/mix/tasks/compile.all.ex:72: Mix.Tasks.Compile.All.compile/4
  (mix 1.13.3) lib/mix/tasks/compile.all.ex:59: Mix.Tasks.Compile.All.with_logger_app/2
  (mix 1.13.3) lib/mix/tasks/compile.all.ex:36: Mix.Tasks.Compile.All.run/1

Compiling 14 files (.ex)
Generated phx_html_cookieless app
The database for PhxHtmlCookieless.Repo has been created

We get the app up and running

MIX_ENV=dev iex -S mix phx.server

Curl the app and see the cookie

niccolox@niccolox-xps:~/Projects/Devekko/phx_html_cookieless$ curl -I http://www.ost:4000
HTTP/1.1 200 OK
cache-control: max-age=0, private, must-revalidate
content-length: 2358
content-type: text/html; charset=utf-8
cross-origin-window-policy: deny
date: Sat, 10 Sep 2022 19:44:50 GMT
server: Cowboy
x-content-type-options: nosniff
x-download-options: noopen
x-frame-options: SAMEORIGIN
x-permitted-cross-domain-policies: none
x-request-id: FxOXV76Mi34Gm5UAAADC
x-xss-protection: 1; mode=block
set-cookie: _phx_html_cookieless_key=SFMyNTY.g3QAAAABbQAAAAtfY3NyZl90b2tlbm0AAAAYaDZsZk9MZm14ejJ1bDBfWlphQmxXOTdr.FkqSdNU9VpLGE2LBl4_ElaXvrA_8TX0UEcC3KhN4zu4; path=/; HttpOnly

Initiate the Git repo on Github

niccolox@niccolox-xps:~/Projects/Devekko/phx_html_cookieless$ git status
fatal: not a git repository (or any of the parent directories): .git
niccolox@niccolox-xps:~/Projects/Devekko/phx_html_cookieless$ git init
Initialized empty Git repository in /home/niccolox/Projects/Devekko/phx_html_cookieless/.git/
niccolox@niccolox-xps:~/Projects/Devekko/phx_html_cookieless$ git add .
niccolox@niccolox-xps:~/Projects/Devekko/phx_html_cookieless$ git commit -am "How to generate Sessionless, Cookie-Free Cacheable HTML"
[master (root-commit) 9b94e77] How to generate Sessionless, Cookie-Free Cacheable HTML
 46 files changed, 1764 insertions(+)
 create mode 100644 .formatter.exs
 create mode 100644 .gitignore
 create mode 100644 README.md
 create mode 100644 assets/css/app.css
 create mode 100644 assets/css/phoenix.css
 create mode 100644 assets/js/app.js
 create mode 100644 assets/vendor/topbar.js
 create mode 100644 config/config.exs
 create mode 100644 config/dev.exs
 create mode 100644 config/prod.exs
 create mode 100644 config/runtime.exs
 create mode 100644 config/test.exs
 create mode 100644 lib/phx_html_cookieless.ex
 create mode 100644 lib/phx_html_cookieless/application.ex
 create mode 100644 lib/phx_html_cookieless/mailer.ex
 create mode 100644 lib/phx_html_cookieless/repo.ex
 create mode 100644 lib/phx_html_cookieless_web.ex
 create mode 100644 lib/phx_html_cookieless_web/controllers/page_controller.ex
 create mode 100644 lib/phx_html_cookieless_web/endpoint.ex
 create mode 100644 lib/phx_html_cookieless_web/gettext.ex
 create mode 100644 lib/phx_html_cookieless_web/router.ex
 create mode 100644 lib/phx_html_cookieless_web/telemetry.ex
 create mode 100644 lib/phx_html_cookieless_web/templates/layout/app.html.heex
 create mode 100644 lib/phx_html_cookieless_web/templates/layout/live.html.heex
 create mode 100644 lib/phx_html_cookieless_web/templates/layout/root.html.heex
 create mode 100644 lib/phx_html_cookieless_web/templates/page/index.html.heex
 create mode 100644 lib/phx_html_cookieless_web/views/error_helpers.ex
 create mode 100644 lib/phx_html_cookieless_web/views/error_view.ex
 create mode 100644 lib/phx_html_cookieless_web/views/layout_view.ex
 create mode 100644 lib/phx_html_cookieless_web/views/page_view.ex
 create mode 100644 mix.exs
 create mode 100644 mix.lock
 create mode 100644 priv/gettext/en/LC_MESSAGES/errors.po
 create mode 100644 priv/gettext/errors.pot
 create mode 100644 priv/repo/migrations/.formatter.exs
 create mode 100644 priv/repo/seeds.exs
 create mode 100644 priv/static/favicon.ico
 create mode 100644 priv/static/images/phoenix.png
 create mode 100644 priv/static/robots.txt
 create mode 100644 test/phx_html_cookieless_web/controllers/page_controller_test.exs
 create mode 100644 test/phx_html_cookieless_web/views/error_view_test.exs
 create mode 100644 test/phx_html_cookieless_web/views/layout_view_test.exs
 create mode 100644 test/phx_html_cookieless_web/views/page_view_test.exs
 create mode 100644 test/support/conn_case.ex
 create mode 100644 test/support/data_case.ex
 create mode 100644 test/test_helper.exs
niccolox@niccolox-xps:~/Projects/Devekko/phx_html_cookieless$ git branch -M main
niccolox@niccolox-xps:~/Projects/Devekko/phx_html_cookieless$ git remote add origin [email protected]:niccolox/phx_html_cookieless.git
niccolox@niccolox-xps:~/Projects/Devekko/phx_html_cookieless$ git push -u origin main
Enumerating objects: 74, done.
Counting objects: 100% (74/74), done.
Delta compression using up to 16 threads
Compressing objects: 100% (66/66), done.
Writing objects: 100% (74/74), 44.02 KiB | 8.80 MiB/s, done.
Total 74 (delta 0), reused 0 (delta 0)
To github.com:niccolox/phx_html_cookieless.git
 * [new branch]      main -> main
Branch 'main' set up to track remote branch 'main' from 'origin'

Add new Cookieless endpoint to application

defmodule PhxHtmlCookieless.Application do
  # See https://hexdocs.pm/elixir/Application.html
  # for more information on OTP Applications
  @moduledoc false

  use Application

  @impl true
  def start(_type, _args) do
    children = [
      # Start the Ecto repository
      PhxHtmlCookieless.Repo,
      # Start the Telemetry supervisor
      PhxHtmlCookielessWeb.Telemetry,
      # Start the PubSub system
      {Phoenix.PubSub, name: PhxHtmlCookieless.PubSub},
      # Start the Endpoint (http/https)
      PhxHtmlCookielessWeb.Endpoint,
      PhxHtmlCookielessWeb.CookielessEndpoint
      # Start a worker by calling: PhxHtmlCookieless.Worker.start_link(arg)
      # {PhxHtmlCookieless.Worker, arg}
    ]

    # See https://hexdocs.pm/elixir/Supervisor.html
    # for other strategies and supported options
    opts = [strategy: :one_for_one, name: PhxHtmlCookieless.Supervisor]
    Supervisor.start_link(children, opts)
  end

  # Tell Phoenix to update the endpoint configuration
  # whenever the application is updated.
  @impl true
  def config_change(changed, _new, removed) do
    PhxHtmlCookielessWeb.Endpoint.config_change(changed, removed)
    :ok
  end
end

Add a Cookieless view to definition file

  def cookielessview do
    quote do
      use Phoenix.View,
        root: "lib/phx_html_cookieless_web/templates",
        namespace: PhxHtmlCookielessWeb

      # Import convenience functions from controllers
      import Phoenix.Controller,
        only: [view_module: 1, view_template: 1]

      # Include shared imports and aliases for views
      unquote(view_helpers())
    end
  end

Add a Cookieless Page controller

defmodule PhxHtmlCookielessWeb.CookielessPageController do
  use PhxHtmlCookielessWeb, :controller
  plug :put_layout, "cookielessapp.html"

  def index(conn, _params) do
    render(conn, "index.html")
  end
end

Add a Cookieless endpoint with no session or flash

defmodule PhxHtmlCookielessWeb.CookielessEndpoint do
  use Phoenix.Endpoint, otp_app: :phx_html_cookieless

  # Code reloading can be explicitly enabled under the
  # :code_reloader configuration of your endpoint.
  if code_reloading? do
    socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
    plug Phoenix.LiveReloader
    plug Phoenix.CodeReloader
    plug Phoenix.Ecto.CheckRepoStatus, otp_app: :phx_html_cookieless
  end

  plug Plug.RequestId
  plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]

  plug Plug.MethodOverride
  plug Plug.Head
  plug PhxHtmlCookielessWeb.Router
end

Add a Cache Control Plug to add caching and cache if stale cache control for cookieless routes

defmodule PhxHtmlCookielessWeb.Plug.CacheControl do
  @moduledoc """
  Manages the adding of cache-control headers to public requests so CDN
  can do some caching
  """
  import Plug.Conn

  def init(opts), do: opts

  def call(conn = %{assigns: %{current_user: user}}, _opts) when not is_nil(user), do: conn

  # https://developers.cloudflare.com/cache/about/cache-control/
  # Cache assets with revalidation, but allow stale responses if origin server is unreachable
  # https://developer.fastly.com/learning/concepts/cache-freshness/#surrogate-control
  def call(conn, _opts) do
    conn
    |> put_resp_header("cache-control", "max-age=3600, stale-while-revalidate=60, stale-if-error=604800")
    |> put_resp_header(
         "surrogate-control",
         "max-age=3600, stale-while-revalidate=60, stale-if-error=604800"
       )
  end
end

Adjust the router with a Cookieless sessionless pipeline and scope for the static, cookieless pathes

defmodule PhxHtmlCookielessWeb.Router do
  use PhxHtmlCookielessWeb, :router

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, {PhxHtmlCookielessWeb.LayoutView, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
  end


  pipeline :browserstatic do
    plug :accepts, ["html"]
    plug :put_root_layout, {PhxHtmlCookielessWeb.LayoutView, :rootcookieless}
    plug PhxHtmlCookielessWeb.Plug.CacheControl
  end

  pipeline :api do
    plug :accepts, ["json"]
  end

  scope "/", PhxHtmlCookielessWeb do
    pipe_through :browser

    get "/", PageController, :index
  end

  scope "/cookieless", PhxHtmlCookielessWeb do
    pipe_through :browserstatic

    get "/", CookielessPageController, :index
  end

  # Other scopes may use custom stacks.
  # scope "/api", PhxHtmlCookielessWeb do
  #   pipe_through :api
  # end

  # Enables LiveDashboard only for development
  #
  # If you want to use the LiveDashboard in production, you should put
  # it behind authentication and allow only admins to access it.
  # If your application does not have an admins-only section yet,
  # you can use Plug.BasicAuth to set up some basic authentication
  # as long as you are also using SSL (which you should anyway).
  if Mix.env() in [:dev, :test] do
    import Phoenix.LiveDashboard.Router

    scope "/" do
      pipe_through :browser

      live_dashboard "/dashboard", metrics: PhxHtmlCookielessWeb.Telemetry
    end
  end

  # Enables the Swoosh mailbox preview in development.
  #
  # Note that preview only shows emails that were sent by the same
  # node running the Phoenix server.
  if Mix.env() == :dev do
    scope "/dev" do
      pipe_through :browser

      forward "/mailbox", Plug.Swoosh.MailboxPreview
    end
  end
end

Add a cookieless_page template folder and index file

<section class="phx-hero">
  <h1><%= gettext "Welcome to %{name}!", name: "Cookieless Phoenix HTML" %></h1>
  <p>Peace of mind from prototype to production</p>
</section>

Add a cookieless app root layout for the cookieless page controller

<main class="container">
  <%= @inner_content %>
</main>

Add a layout for cookieless which has no CSRF, no sessions, no JS.

Obviously this is crude and not realistic for production, we probably need a second JS file with no CSRF.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
    <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
    <%= live_title_tag assigns[:page_title] || "PhxHtmlCookieless", suffix: " · Phoenix Framework" %>
    <link phx-track-static rel="stylesheet" href={Routes.static_path(@conn, "/assets/app.css")}/>

  </head>
  <body>
    <header>
      <section class="container">
        <nav>
        </nav>
        <a href="https://phoenixframework.org/" class="phx-logo">
          <img src={Routes.static_path(@conn, "/images/phoenix.png")} alt="Phoenix Framework Logo"/>
        </a>
      </section>
    </header>
    <%= @inner_content %>
  </body>
</html>

We create a cookieless page view which references our cookieless view definition, giving us no flash or session: view

defmodule PhxHtmlCookielessWeb.CookielessPageView do
  use PhxHtmlCookielessWeb, :cookielessview
end

You can see all these commits on Github

Validate in cURL

Cookieless scope

Now, In curl we can see the cookieless cacheable /cookieless scope

curl -I http://www.ost:4000/cookieless
HTTP/1.1 200 OK
cache-control: max-age=3600, stale-while-revalidate=60, stale-if-error=604800
content-length: 905
content-type: text/html; charset=utf-8
date: Sat, 10 Sep 2022 21:13:40 GMT
server: Cowboy
surrogate-control: max-age=3600, stale-while-revalidate=60, stale-if-error=604800
x-request-id: FxOcMLOpAdCZttoAAAAK

Cookie scope

Vs the default homepage, which is not cacheable and has cookies and sessions.

 curl -I http://www.ost:4000
HTTP/1.1 200 OK
cache-control: max-age=0, private, must-revalidate
content-length: 2358
content-type: text/html; charset=utf-8
cross-origin-window-policy: deny
date: Sat, 10 Sep 2022 21:14:35 GMT
server: Cowboy
x-content-type-options: nosniff
x-download-options: noopen
x-frame-options: SAMEORIGIN
x-permitted-cross-domain-policies: none
x-request-id: FxOcPakeOME1JvIAAABK
x-xss-protection: 1; mode=block
set-cookie: _phx_html_cookieless_key=SFMyNTY.g3QAAAABbQAAAAtfY3NyZl90b2tlbm0AAAAYdGJSeE8zX1pDNktYejZDYWwyN2Z6YTVn.TrZZ8ympcUrnhHPyzCSzeDA1flhRUmr0Gqn9prYWKz0; path=/; HttpOnly

Conclusion

IF you are hosting a website with MANY pages, you want to be able to cache HTML in Varnish or Cloudflare or Fastly. You don't need authentication on landing pages.

More advanced techniques would be to route in the Phoenix app by Cookies. Say if, a user visited the cookieless page with a backend, authenticated user cookie, either the Phoenix default or a cookie from a cart, then the router switches the scope to or controller and personalizes the page.

These kinds of caching issues can save money, speed up the site and generally improve the UX.

📨

Sign Up For Newsletters

Get E-mail updates about our website content & special offers