Sending cookies for stateless SPA authentication using JWT

api
phoenix
spa
session
jwt
Tags: #<Tag:0x00007f039cef1b58> #<Tag:0x00007f039cef1a18> #<Tag:0x00007f039cef1888> #<Tag:0x00007f039cef16d0> #<Tag:0x00007f039cef1568>

#1

Hello y’all!

I’m very new to the Elixir/Phoenix beauty but very excited to learn more!

I’m building a Phoenix json API with a (separate) Vue.js SPA. I’m at the point where I need to build a secured authentication system as my app will be used in production eventually.

I read a lot on the subject (spent 2 full days on this alone) and I like the idea of restful stateless sessions using JWT. My main concern is that although it seems to be the preferred approach, sending JWT directly as a json response and storing it in HTML5 local storage is very unsafe and shouldn’t be done at all.

This post enlightened me a lot on the subject: https://www.rdegges.com/2018/please-stop-using-local-storage/

Now, the best approach seems to be using http only cookies. The post I found the most interesting on the subject and close to my needs is this one: https://medium.com/lightrail/getting-token-authentication-right-in-a-stateless-single-page-application-57d0c6474e3

I’m sharing those links should anyone be interested in reading them don’t spend 2 days searching for them :smiley:

Now my question : Is there (I’m sure there is) a way to send, as the last post I shared explains, 2 cookies for user authentication/session, without storing the session on the server?

I have a working Guardian setup which generates JWT successfully and sends it to my client side in a response (the approach I’d like to avoid using). I just need to find a way to

  1. Split the JWT
  2. Send the 2 cookies to the client, one of which should be http only

I tried to find a solution in the online documentations and can’t seem to find the best way to do it. Speaking of documentation, is there a place where Phoenix 1.3 is fully documented? Hexdocs always seems incomplete and, in this case, doesn’t seem to cover sessions. https://hexdocs.pm/phoenix/overview.html The Programming Phoenix 1.0 book doesn’t help much either.

Thank’s for any help! <3


#2

You might have missed this post :slight_smile:

http://cryto.net/~joepie91/blog/2016/06/13/stop-using-jwt-for-sessions/

It is possible to do the same as JWT with Phoenix.Token, but with a lighter token.

I am doing the same, although it is with React and Redux. I am storing a Phoenix.Token inside Redux application store. This token allows me to access private routes, and also secure my websockets.

So I would store the token inside the Vue store manager (Vuex?)

I have been doing the same with swift, and there is a secure KeyChain storage where to put tokens. But there is no real equivalent in js world…


#3

To help with what you’re looking for: https://hexdocs.pm/plug/Plug.Session.html and https://hexdocs.pm/plug/Plug.Session.COOKIE.html

JWT’s expire and it’s typical to have them have a short life. So, even if they were in local storage, they should become invalid quickly. Anyways, you can put them in session storage, which has the exact same API as local storage, but the browser will delete the data after the user closes the tab/browser. Also, multiple tabs are isolated from each other, so you could be logged in as two different users and have no side effects.


#4

The default session implementation of phoenix does not store things serverside in the first place. Everything is just put in a signed token and sent as cookie.


#5

Thank you so much guys for your timely answers! I’ll have a serious look at all your answers and come back to you.

Deeply appreciated! <3


#6

Here is a tutrial about secured use of JWT with memcached and lua:


#7

Firstly, thank you again for your answers!

Enlightened by your replies, I chose the following approach: Phoenix token sent with secure HttpOnly cookie over HTTPS.

I feel like I’m almost there but I can’t seem to find a solution to my (hopefully) last problem.

Phoenix puts the cookie in the response header as expected and I can see it in Chrome Response Headers, which is good.

However, I can’t seem to send the cookie back to the server with my next call. Maybe you guys will see where I’m missing something, I feel like it’s a detail. Here’s my setup:

Phoenix API running on localhost:4000

Cors plug enabled and working, with this in my endpoint.ex file: plug CORSPlug, origin: ["http://localhost:8080"]

Method responsible for putting the cookie in the response (user controller):

  def sign_in(conn, %{"user" => user_params}) do
    case Accounts.token_sign_in(conn, user_params["username"], user_params["password"]) do
      {:ok, token, user} ->
        conn
        |> Plug.Conn.put_resp_cookie("token", token, http_only: true, secure: true, max_age: 604800)
        |> render("signed_in.json", user: user)
      _ ->
        {:error, :unauthorized}
    end
  end

Vue.js app running on localhost:8080

Those 2 methods used to test my authentification:

methods: {
  signIn: function () {
    this.axios.post(process.env.SERVER_BASE_URL + '/sign_in', {
      'user': {
        'username': 'joeystl434',
        'password': 'somePassword'
      }
    }).then(response => {
      this.signUpResponse = response
    })
  },
  accessSecureData: function () {
    this.axios.post(process.env.SERVER_BASE_URL + '/access_secure_data').then(response => {
      this.accessSecureDataResponse = response
    })
  }
}

Calls

So, when I call the signIn method, this happens in Chrome:

Preflight

General
  Request URL: http://localhost:4000/api/sign_in
  Request Method: OPTIONS
  Status Code: 204 No Content
  Remote Address: 127.0.0.1:4000
  Referrer Policy: no-referrer-when-downgrade
Response Headers
  access-control-allow-credentials: true
  access-control-allow-headers: Authorization,Content-Type,Accept,Origin,User-Agent,DNT,Cache-Control,X-Mx-ReqToken,Keep-Alive,X-Requested-With,If-Modified-Since,X-CSRF-Token
  access-control-allow-methods: GET,POST,PUT,PATCH,DELETE,OPTIONS
  access-control-allow-origin: http://localhost:8080
  access-control-expose-headers: 
  access-control-max-age: 1728000
  cache-control: max-age=0, private, must-revalidate
  content-length: 0
  date: Thu, 17 May 2018 15:33:42 GMT
  server: Cowboy
  vary: Origin
Request Headers
  Accept: */*
  Accept-Encoding: gzip, deflate, br
  Accept-Language: en-US,en;q=0.9,fr;q=0.8
  Access-Control-Request-Headers: content-type
  Access-Control-Request-Method: POST
  Connection: keep-alive
  Host: localhost:4000
  Origin: http://localhost:8080
  User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.139 Safari/537.36

Actual sign-in (we can see the cookie being set)

General
  Request URL: http://localhost:4000/api/sign_in
  Request Method: POST
  Status Code: 200 OK
  Remote Address: 127.0.0.1:4000
  Referrer Policy: no-referrer-when-downgrade
Response Headers
  access-control-allow-credentials: true
  access-control-allow-origin: http://localhost:8080
  access-control-expose-headers: 
  cache-control: max-age=0, private, must-revalidate
  content-length: 60
  content-type: application/json; charset=utf-8
  date: Thu, 17 May 2018 15:33:43 GMT
  server: Cowboy
  set-cookie: token=SFMyNTY.g3QAAAACZAAEZGF0YWEBZAAGc2lnbmVkbgYAUUy8bmMB.ZKR0G_urmLdhSQv7h2PbOh5GBBAQXnp3iuMjwYIPO-Q; path=/; expires=Thu, 24 May 2018 15:33:44 GMT; max-age=604800; secure; HttpOnly
  vary: Origin
Request Headers
  Accept: application/json, text/plain, */*
  Accept-Encoding: gzip, deflate, br
  Accept-Language: en-US,en;q=0.9,fr;q=0.8
  Connection: keep-alive
  Content-Length: 60
  Content-Type: application/json;charset=UTF-8
  Host: localhost:4000
  Origin: http://localhost:8080
  Referer: http://localhost:8080/
  User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.139 Safari/537.36
  {user: {username: "joeystl434", password: "somePassword"}}
  user
  :
  {username: "joeystl434", password: "somePassword"}

And then, I run the accessSecureData method:

General
  Request URL: http://localhost:4000/api/access_secure_data
  Request Method: POST
  Status Code: 200 OK
  Remote Address: 127.0.0.1:4000
  Referrer Policy: no-referrer-when-downgrade
Response Headers
  access-control-allow-credentials: true
  access-control-allow-origin: http://localhost:8080
  access-control-expose-headers: 
  cache-control: max-age=0, private, must-revalidate
  content-length: 27
  content-type: application/json; charset=utf-8
  date: Thu, 17 May 2018 15:35:51 GMT
  server: Cowboy
  vary: Origin
Request Headers
  Accept: application/json, text/plain, */*
  Accept-Encoding: gzip, deflate, br
  Accept-Language: en-US,en;q=0.9,fr;q=0.8
  Connection: keep-alive
  Content-Length: 0
  Host: localhost:4000
  Origin: http://localhost:8080
  Referer: http://localhost:8080/
  User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.139 Safari/537.36

Response: {"data":"Very secure data"} (as expected because I don’t check if the user is authorized yet)

No cookie, and I don’t understand why.

And when I pry here:

  def access_secure_data(conn, _params) do
    test = Plug.Conn.fetch_cookies(conn)

    require IEx; IEx.pry

    conn
    |> render("secure_data.json", data: "Very secure data")
  end

I can’t seem to access the cookie.

test =

%Plug.Conn{
  adapter: {Plug.Adapters.Cowboy.Conn, :...},
  assigns: %{},
  before_send: [#Function<1.92707701/1 in Plug.Logger.call/2>],
  body_params: %{},
  cookies: %{},
  halted: false,
  host: "localhost",
  method: "POST",
  owner: #PID<0.624.0>,
  params: %{},
  path_info: ["api", "access_secure_data"],
  path_params: %{},
  peer: {{127, 0, 0, 1}, 35589},
  port: 4000,
  private: %{
    MyAppWeb.Router => {[], %{}},
    :phoenix_action => :access_secure_data,
    :phoenix_controller => MyAppWeb.UserController,
    :phoenix_endpoint => MyAppWeb.Endpoint,
    :phoenix_format => "json",
    :phoenix_layout => {MyAppWeb.LayoutView, :app},
    :phoenix_pipelines => [:api],
    :phoenix_router => MyAppWeb.Router,
    :phoenix_view => MyAppWeb.UserView,
    :plug_session_fetch => #Function<1.45862765/1 in Plug.Session.fetch_session/1>
  },
  query_params: %{},
  query_string: "",
  remote_ip: {127, 0, 0, 1},
  req_cookies: %{},
  req_headers: [
    {"host", "localhost:4000"},
    {"connection", "keep-alive"},
    {"content-length", "0"},
    {"accept", "application/json, text/plain, */*"},
    {"origin", "http://localhost:8080"},
    {"user-agent",
     "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.139 Safari/537.36"},
    {"referer", "http://localhost:8080/"},
    {"accept-encoding", "gzip, deflate, br"},
    {"accept-language", "en-US,en;q=0.9,fr;q=0.8"}
  ],
  request_path: "/api/access_secure_data",
  resp_body: nil,
  resp_cookies: %{},
  resp_headers: [
    {"cache-control", "max-age=0, private, must-revalidate"},
    {"vary", "Origin"},
    {"access-control-allow-origin", "http://localhost:8080"},
    {"access-control-expose-headers", ""},
    {"access-control-allow-credentials", "true"}
  ],
  scheme: :http,
  script_name: [],
  secret_key_base: "wEwpD63zVGtA3/JTdl5QBH6aZwE3FLD2gkAQWn6XD4Tfgk4lYlsDOoZHAREvfjoX",
  state: :unset,
  status: nil
}

conn =

%Plug.Conn{
  adapter: {Plug.Adapters.Cowboy.Conn, :...},
  assigns: %{},
  before_send: [#Function<1.92707701/1 in Plug.Logger.call/2>],
  body_params: %{},
  cookies: %Plug.Conn.Unfetched{aspect: :cookies},
  halted: false,
  host: "localhost",
  method: "POST",
  owner: #PID<0.624.0>,
  params: %{},
  path_info: ["api", "access_secure_data"],
  path_params: %{},
  peer: {{127, 0, 0, 1}, 35589},
  port: 4000,
  private: %{
    MyAppWeb.Router => {[], %{}},
    :phoenix_action => :access_secure_data,
    :phoenix_controller => MyAppWeb.UserController,
    :phoenix_endpoint => MyAppWeb.Endpoint,
    :phoenix_format => "json",
    :phoenix_layout => {MyAppWeb.LayoutView, :app},
    :phoenix_pipelines => [:api],
    :phoenix_router => MyAppWeb.Router,
    :phoenix_view => MyAppWeb.UserView,
    :plug_session_fetch => #Function<1.45862765/1 in Plug.Session.fetch_session/1>
  },
  query_params: %{},
  query_string: "",
  remote_ip: {127, 0, 0, 1},
  req_cookies: %Plug.Conn.Unfetched{aspect: :cookies},
  req_headers: [
    {"host", "localhost:4000"},
    {"connection", "keep-alive"},
    {"content-length", "0"},
    {"accept", "application/json, text/plain, */*"},
    {"origin", "http://localhost:8080"},
    {"user-agent",
     "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.139 Safari/537.36"},
    {"referer", "http://localhost:8080/"},
    {"accept-encoding", "gzip, deflate, br"},
    {"accept-language", "en-US,en;q=0.9,fr;q=0.8"}
  ],
  request_path: "/api/access_secure_data",
  resp_body: nil,
  resp_cookies: %{},
  resp_headers: [
    {"cache-control", "max-age=0, private, must-revalidate"},
    {"vary", "Origin"},
    {"access-control-allow-origin", "http://localhost:8080"},
    {"access-control-expose-headers", ""},
    {"access-control-allow-credentials", "true"}
  ],
  scheme: :http,
  script_name: [],
  secret_key_base: "wEwpD63zVGtA3/JTdl5QBH6aZwE3FLD2gkAQWn6XD4Tfgk4lYlsDOoZHAREvfjoX",
  state: :unset,
  status: nil
}

Any help is always deeply appreciated.

Thank you in advance for your time and expertise <3


#8

Have you set axios to send the cookie with each request?

axios.defaults.withCredentials = true

#9

Yes, in my main.js file

# Pruned for conciseness
Vue.use(VueAxios, axios)

axios.defaults.withCredentials = true
# Pruned for conciseness

#10

@jstlroot, would you mind explaining how does using a cookie provide better security than sending the JWT token in the request header body?


#11

Of course! I’m no expert but what I learned from reading articles and blog posts for 3 days is that giving javascript access to an authentication token (jwt or phoenix) is never a good idea because of cross-site scripting attacks.

To quote this article: https://www.rdegges.com/2018/please-stop-using-local-storage/

If an attacker can run JavaScript on your website, they can retrieve all the data you’ve stored in local storage and send it off to their own domain. This means anything sensitive you’ve got in local storage (like a user’s session data) can be compromised.

If an attacker can get a copy of your JWT, they can make requests to the website on your behalf and you will never know. Treat your JWTs like you would a credit card number or password: don’t ever store them in local storage.

It seems like using the request header body to send the token also gives javascript access to it. There might be a way to avoid this that I don’t know of? If so, it could be a solution.

HttpOnly cookies are isolated from javascript and cannot be read by it, making authentication token stealing XSS attacks more complex, if not impossible. Cookies are open to other types of attacks but this is mitigated by the use of HTTPS all over. At least that’s what I understood.

Maybe there are some things I don’t quite understand yet and if you feel like it’s the case, please point it out so I can avoid doing dangerous mistakes. :smile:


#12

Have you tried turning off HttpOnly (in this case not setting it to true) when setting the cookie?


#13

Yes.

  def sign_in(conn, %{"user" => user_params}) do
    case Accounts.token_sign_in(conn, user_params["username"], user_params["password"]) do
      {:ok, token, user} ->
        conn
        |> Plug.Conn.put_resp_cookie("token", token, http_only: false, secure: true, max_age: 604800)
        |> render("signed_in.json", user: user)
      _ ->
        {:error, :unauthorized}
    end
  end

The access_secure_data gives me exactly the same output:

%Plug.Conn{
  adapter: {Plug.Adapters.Cowboy.Conn, :...},
  assigns: %{},
  before_send: [#Function<1.92707701/1 in Plug.Logger.call/2>],
  body_params: %{"withCredentials" => true},
  cookies: %{},
  halted: false,
  host: "localhost",
  method: "POST",
  owner: #PID<0.799.0>,
  params: %{"withCredentials" => true},
  path_info: ["api", "access_secure_data"],
  path_params: %{},
  peer: {{127, 0, 0, 1}, 47456},
  port: 4000,
  private: %{
    MyAppWeb.Router => {[], %{}},
    :phoenix_action => :access_secure_data,
    :phoenix_controller => MyAppWeb.UserController,
    :phoenix_endpoint => MyAppWeb.Endpoint,
    :phoenix_format => "json",
    :phoenix_layout => {MyAppWeb.LayoutView, :app},
    :phoenix_pipelines => [:api],
    :phoenix_router => MyAppWeb.Router,
    :phoenix_view => MyAppWeb.UserView,
    :plug_session_fetch => #Function<1.45862765/1 in Plug.Session.fetch_session/1>
  },
  query_params: %{},
  query_string: "",
  remote_ip: {127, 0, 0, 1},
  req_cookies: %{},
  req_headers: [
    {"host", "localhost:4000"},
    {"connection", "keep-alive"},
    {"content-length", "24"},
    {"accept", "application/json, text/plain, */*"},
    {"origin", "http://localhost:8080"},
    {"user-agent",
     "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.139 Safari/537.36"},
    {"content-type", "application/json;charset=UTF-8"},
    {"referer", "http://localhost:8080/"},
    {"accept-encoding", "gzip, deflate, br"},
    {"accept-language", "en-US,en;q=0.9,fr;q=0.8"}
  ],
  request_path: "/api/access_secure_data",
  resp_body: nil,
  resp_cookies: %{},
  resp_headers: [
    {"cache-control", "max-age=0, private, must-revalidate"},
    {"vary", "Origin"},
    {"access-control-allow-origin", "http://localhost:8080"},
    {"access-control-expose-headers", ""},
    {"access-control-allow-credentials", "true"}
  ],
  scheme: :http,
  script_name: [],
  secret_key_base: "wEwpD63zVGtA3/JTdl5QBH6aZwE3FLD2gkAQWn6XD4Tfgk4lYlsDOoZHAREvfjoX",
  state: :unset,
  status: nil
}

Maybe something to add to the discussion, using Postman never helps either. i.e. In this case, it sees my cookie being set as expected on the sign_in call but the access_secure_data call is not sending back my cookie to the server even though I can see it in Postman…


#14

Very very true. Plus if you encode, say, a JWT token into a web page (as is common with ‘any’ token for phoenix websockets sadly) then a page can quite literally just read another page and parse out information from it using your auth’d user (there are hazards they have to work around to do that, but there are still ways).


#15

Oh my god I found it…

I knew it was gonna be stupid.

put_resp_cookie(conn, key, value, opts \\ [])

Options

  • :domain - the domain the cookie applies to
  • :max_age - the cookie max-age, in seconds. Providing a value for this option will set both the max-age and expires cookie attributes
  • :path - the path the cookie applies to
  • :http_only - when false, the cookie is accessible beyond http
  • :secure - if the cookie must be sent only over https. Defaults to true when the connection is https
  • :extra - string to append to cookie. Use this to take advantage of non-standard cookie attributes.

I had :secure forced to true but I’m not using HTTPS in my dev environment.

I hope this discussion will help others determine how they want to implement token authentification with Phoenix and Vue.js :slight_smile:

Thank you all again for your time and support <3


#16

@jstlroot, glad to hear it is working now. I have already implemented your ideas (in regard to secure cookie storage for the token) using Phauxth library. I want to ask: what max_age do you use for the token/cookie expiration (should it be same value)? and do you implement some refresh token mechanism server or client side (for soon to get expired tokens)? Thank you.


#17

Hi @acrolink

I was out for a couple of days, hence the delay, I’m sorry.

To be honest, I haven’t yet decided how the session expiration will work for my app. What I’d like is for the session to expire after a week of inactivity for the account. At least, that’s what I think my customers will find appropriate.

Many options seem possible. One would be to refresh the token and overwrite the cookie at each request, which shouldn’t be too much of a drag for the server as this process seems very light. Another option would be to be less aggressive about it and go for daily refreshes (first request of the day refreshes the token and cookie).

Another more lazy way would be to set the expiration 2 weeks after cookie/token creation and refresh them when there’s less than one week remaining. The only way this option would make sense to go for is to save server load, which I think is not really an issue, so I most likely won’t go for this one.

I’d be interested to know our thoughts on the subject!

Ah, and yes, I think the cookie and token should have the same expiration value as the behavior is pretty much the same should either of them expire before the other anyway (user needing to log back in).

Cheers! :beers:


#18

Would be interesting to see what the optimal solution would look like :slight_smile: If the client side is a pure JS application then it is possible to send back to the client application at authentication time (sign in) along with the cookie the value of token expiration (e.g. {"expires_at": unix_time}). A script running at fixed intervals on the client-side or on page navigation checks if there is say less than 24 hours to expiration. If so, a background call to the server is made which triggers the sever to issue a new cookie with new expiration time. Good to make distinction between original token and refreshed tokens since some actions may require providing username and password again.


#19

Yep, makes sense.

Your idea of making distinction between original and refreshed tokens is interesting, thank you for sharing it!

Don’t hesitate to poke me as needed or to share any other idea. :smile: