Node Authentication With Google OAuth: Part 2 (JWTs)
Introduction
In Part 1, I demonstrated how to set up Google authentication in your Node / Express app. We then kept track of our "logged-in" users using cookies and sessions.
In Part 2, we'll be doing the same Google authentication, but instead we'll keep track of our "logged-in" users using the newer, hotter approach of JSON Web Tokens (JWTs).
Cookies and sessions are a great way to keep track of users in web applications. JWTs are also great, and have some advantages over cookies.
Assumptions
I'll assume you're here primarily because you'd prefer to use JWTs to track user state in your application. If you're already pretty familiar with JWTs and Single-page applications (SPAs), you'll likely be able to skip straight to the "Solutions" section.
Motivation
This section will attempt to answer the questions: "Why didn't we just stop at Part 1?" and "Why don't we just use cookies all the time?".
TL;DR: Stopping at Part 1 is perfectly reasonable. If the below doesn't apply to you, using cookies + sessions is a perfectly reasonable approach to keeping users logged in to your web app.
Motivation 1: JavaScript Requests
Single-page applications (SPAs) usually don't rely on the server to send dynamically-rendered HTML. Usually, a single HTML page is loaded, along with a front-end framework (like React / Redux) and a lot of JavaScript. After that initial page load, subsequent requests to the server are usually sent by JavaScript behind-the-scenes to get raw data, not to navigate in the browser to an entirely new page. Many SPAs actually handle navigation on the front-end (like with React Router).
The result of all this is that when SPAs make requests to a server, the requests are sent via JavaScript, not via browser navigation. And, if we're making requests with JavaScript, those requests will not include our cookies by default, so our site won't recognize that the user is logged in, and the requests will fail with "permission denied" (unless we change something).
Motivation 2: JWTs vs Cookies
Auth0 has a great article on the differences between Cookie-based and Token-based authentication. This is a great place to start if you want to refresh yourself on JWTs and on the differences between the 2 approaches.
Some advantages of JWTs include:
- JWTs allow login/logout without managing logged-in state on the server.
- This means we can scale to many servers without needing to track the state of logged-in users across all of those servers.
- JWTs are generally stored in localStorage, which has some advantages against certain types of security issues.
- JWTs can be used easily by mobile apps (this means that if you ever build a mobile app in the future, once logged in, users of the mobile app and users of the browser app can both hit the same server endpoints for data).
- It is (arguably) easier to send a JWT with a JavaScript request vs. attaching cookies to a JavaScript request.
- This is primarily true if you're trying to make test requests to your application with something like
curl
or Postman -- all you have to do is add a single header (Authorization: Bearer <token-here>
). - In most cases, however, JavaScript's fetch makes it really easy to send cookies along with a request.
- This is primarily true if you're trying to make test requests to your application with something like
- JWTs are (arguably) simpler and easier to understand.
- Disclaimer: this is probably just my personal preference / personal bias.
- Because JWTs are "signed" by the server, the server doesn't have to keep track of much of anything in order to verify a JWTs validity.
- As a result, JWTs require very little additional code on either the front-end or the back-end.
- As a result, there are less moving parts, and JWTs are easier to understand and debug.
Solutions
So, just to recap some of the reasons we need a Part 2:
- JWTs have some advantages over cookie-based user management.
- At least right now, our finished app from Part 1 won't be able to access protected endpoints with a regular
fetch
request from JavaScript, since the "proof" that the user is logged-in will be inside a cookie. Andfetch
does not send cookie data by default when making requests.
Let's walk through some possible solutions to one or both of these concerns.
Possible Solution 1: Just send the cookie(s) with your JavaScript requests
If your main concern is that behind-the-scenes JavaScript requests will not include cookies, and thus will not have access to protected endpoints, then the easiest solution by far is just to include cookies in those requests.
Assuming you're using the fetch
API (which I recommend), this is as simple as doing the following:
fetch('/protected-endpoint', {
credentials: 'same-origin'
})
Adding credentials: 'same-origin'
to the options
object means that it will automatically include all your cookies from the current site, just like the browser would if you were navigating to that link manually.
And...problem solved. Yes, you're still using cookies, but if your main concern is just being able to access your protected endpoints via JavaScript, you can stop here.
Quick Detour: Why can't we just send the JWT directly to the client once a user logs in?
Normally, you can do exactly this. A client sends an authentication request (ie. with their username and password) using JavaScript, the server sends back a JWT, the client saves that JWT in localStorage
, and now the user is "logged in" using JWTs.
However, with OAuth it goes through another site (like Google), it's not a single request:
- The user clicks the "login" link.
- The client (browser) is redirected to Google.
- The user signs in with Google.
- Google redirects the client back to your site with a "code" GET parameter set in the URL.
- (Behind the scenes, your server then exchanges that "code" for actual user data by sending another set of requests to Google's servers, but that's invisible to the client)
- At this point, it's fairly easy for your server to create a JWT proving that the user is logged in, then redirect the user or do something else.
- BUT, the hard part is getting that JWT back to the client!
- Since there have been multiple steps for the client, the client was "bounced around" from your app to Google and back again. These redirects happen at the "browser level", not through a JavaScript request.
- So, when Google redirects the user back to our "callback" endpoint, somehow the server needs to "push" the JWT to the client on that last redirect.
- Cookies are easier in this regard because almost all browsers will automatically save a cookie if a cookie is sent back from the server.
- (ie. if the server sends a "Set-Cookie" header, the browser will just save the cookie automatically for the current site)
- However, JWTs need to be "manually" saved to
localStorage
, and so it's much easier to get them by sending the server a request using JavaScript (with a username and password), then just saving the JWT that the server sends back. - But, because of the redirects, we can't just send a request from JavaScript to get the login info, it has to go through the chain of Google authentication -> redirect sometime later back to our server.
- So...how are we supposed to get our JWT back to the client?
Hopefully this problem is at least somewhat clear. If it helps, here are some example StackOverflow questions of people struggling with this same issue:
Unless I'm misunderstanding the questions these people are asking, in 2 of the 3 examples the StackOverflow answers don't satisfy the core problem of: what's the recommended way to get JWTs back to the client after OAuth redirects? In the last example, I disagree with the answer.
Ok, detour over, here are some possible solutions I came up with that solve the "JWT" motivation as well.
Option 2: Save a cookie (just like in Part 1), then make another request for a JWT.
As the poster in Stack Overflow Example #3 suggests, one possibility is to store the JWT in a cookie. A nearly identical approach might be: save the cookie just like in Part 1, but then allow the client to extract the JWT from the cookie, then just use the JWT from then on.
One way to implement this might be:
- Just like in the Part 1 Walkthrough the user gets redirected to Google, logs in, gets redirected back to our app, then gets a cookie saved to their browser. That cookie has a JWT inside it.
- However, instead of using that cookie for all protected endpoints, it's possible to make it work for just a single endpoint (eg.
/get-token
). This endpoint could extract the JWT from the cookie, then return the JWT to the client.
- Or if the cookie were accessible from JavaScript and unencrypted, the client could extract the JWT directly.
Advantages:
- Most endpoints on the server would use JWTs, meaning if you added a mobile app later, as long as the mobile app also had a way to get JWTs, your server could be used by both web browser clients and mobile clients easily.
- Scaling to many back-end servers would not be a concern, since the back-end would not need to maintain shared state about which users are logged in (since every endpoint either extracts a JWT from a passed-in cookie, or expects a JWT in the header).
Disadvantages:
- It's slightly complicated -- you'd still need to set up cookies in Passport, and it might be a hassle to configure Passport to only check for a valid user session on the
/get-token
endpoint, and to rely only on JWTs on other endpoints. This isn't the end of the world, but maybe we can do better. - There would still be lots of code necessary to have Passport use cookies -- our app would look very similar to what we built in Part 1, but with additional code to handle the JWT logic.
- If new people join your team, it might be kind of a pain to explain why all this is necessary.
Option 3: After authenticating with Google, redirect somewhere and include the JWT in the URL GET params.
Another possible way to get the JWT to the front-end might be to redirect somewhere and include the entire JWT in the GET parameters (eg. yourapp.com/saveToken?JWT=jwt.is.here
).
For this to work with a single page application, you could have yourapp.com/saveToken
on your server just serve up the base index.html
file, and then have a client-side router set up (like react-router) that will recognize the /saveToken
address and can pull the GET param from the URL and save it automatically to localStorage
.
Advantages:
- This requires no cookies or sessions whatsoever, so we could remove all the session- and cookie-specific code from the Part 1 Walkthrough and do something like this in the Google callback endpoint.
// This is where users point their browsers in order to get logged in
// This is also where Google sends back information to our app once a user authenticates with Google
app.get('/auth/google/callback',
passport.authenticate('google', { failureRedirect: '/login.html', session: false }),
(req, res) => {
const userData = req.user;
const jwt = createJWTFromUserData(req.user);
const redirectURL = '/saveToken?JWT=' + jwt;
res.redirect(redirectURL);
}
);
// We'd also need to give our front-end SPA control over the routing,
// so when it redirects to /saveToken, the server sends back the homepage
// this lets the front-end router take it from there
app.get('/saveToken', function(req, res) {
res.sendFile(__dirname + '/public/index.html')
});
- Our app would be fully on JWTs -- no need to worry about sessions or cookies at all.
Disadvantages:
- Arguably there are some security concerns to this approach. The URL would have the entire JWT in it, so if someone else got a hold of that, they could pretend to be that user when using our application.
- Using HTTPS would mean that the GET params would be "invisible" (encrypted, actually) to "man-in-the-middle" attacks, so that would help mitigate this issue.
- If our app redirects from this page to another page, the
referrer
field might include the JWT, which leaves it exposed.- You might be able to mitigate this by immediately redirecting to the homepage of your app once the JWT is saved.
- I believe there are ways to mitigate most of the security concerns I can think of, but it's still an extra thing to worry about. Also, it's likely there are other security concerns I haven't thought of yet.
- It's simpler to set this up on the back-end, but arguably much more complicated to implement on the front-end. You'd need to set up client-side routing (or some other approach) for getting the JWT out of the URL and saving it.
Option 4: After authenticating with Google, send back a server-rendered HTML page with embedded JS to save the JWT and immediately redirect.
This solution is pretty straightforward. Essentially, "logging in" with JWTs basically consists of saving the JWT to localStorage
. So why not just serve a minimal HTML page with embedded JavaScript that automatically saves the JWT to localStorage
, then immediately redirects somewhere else?
To implement this, we just need to modify the /auth/google/callback
method from Part 1.
app.get('/auth/google/callback',
passport.authenticate('google', { failureRedirect: '/', session: false }),
(req, res) => {
const jwt = createJWTFromUserData(req.user);
const htmlWithEmbeddedJWT = `
<html>
<script>
// Save JWT to localStorage
window.localStorage.setItem('JWT', '${jwt}');
// Redirect browser to root of application
window.location.href = '/';
</script>
</html>
`;
res.send(htmlWithEmbeddedJWT);
}
);
Now, when a user logs in with Google and gets redirected back to /auth/google/callback
, the server renders a tiny HTML page that saves the JWT and then immediately redirects somewhere else.
Advantages
- It's really simple.
- It gets the JWT back to the client easily.
- It doesn't rely on cookies or sessions, so we can skip the cookies/sessions section of the Part 1 Walkthrough.
- It addresses both motivations.
Disadvantages
- There may be some security concerns I'm missing. However, bear with me for a second:
- The answer for StackOverflow Example #3 claims that embedding the JWT in the DOM would open it up to CSRF attacks.
- However, in this case I don't think that's true -- the only way to get to
/auth/google/callback
and NOT have it redirect to Google is to provide acode=<some-token-from-google>
parameter. An invalid "code" value would cause an error before the JWT-embedded HTML was sent to the client. - Also, the DOM won't be visible to an attacker (unless you're not using HTTPS, which would cause unavoidable security issues regardless).
- Feel free to comment if I'm missing something here.
- However, in this case I don't think that's true -- the only way to get to
- The answer for StackOverflow Example #3 claims that embedding the JWT in the DOM would open it up to CSRF attacks.
- Are there any other disadvantages or security concerns I'm missing? I can't think of any, but if you can, let me know!
Best Solution?
To me, Solution #4 seems like the best approach -- it's simple, easy to understand, and very easy to implement with almost no additional code. It also allows us to get rid of sessions/cookies entirely, giving us the advantages of using JWTs throughout our entire application.
Coming Soon
I plan to follow this up with a simple example demonstrating Solution #4. Stay tuned.
Disclaimer: This blog post is not meant to be taken as gospel, these are just my thoughts on the best approaches for using JWTs when using OAuth login. Please let me know or comment below if you have questions or see errors in my conclusions!