Skip to Content

Authentication for NextJS with GraphQL & REST APIs and SSR (Server Side Rendering)

Published: September 09, 2021
Jens Neuse

Jens Neuse

Why I'm so frustrated with Authentication in Single Page Applications#

A while back, I've tried to build a Single Page Application (SPA) using Create React APP (CRA). I wanted to use TypeScript as I'm using it over pure JavsScript for quite some time already. Additionally, I didn't want to build or maintain my own Authentication Service, so I chose Auth0 as OpenID Connect Provider. On paper, it looked simple enough: Configure GitHub and Google as upstream Auth providers, create a client ID and use the Auth0 SDK to hook up everything in my React application.

However, things didn't go that smooth. There were no official TypeScript bindings for the Auth0 SDK, so I had to search through GitHub repositories until I found a solution from Auth0 users. I had to copy & paste a lot of code and wasn't 100% sure if it's going to work. Finally, I've got it working, but I wasn't satisfied with the solution.

If you're using a provider like Auth0, you'll always have to put a Login page on a different domain in front of your website. Custom domains might be possible, or you could even embed the Login into your website directly, but that would mean extra work. Aside from that, you have to add a lot of code to your repository for the authentication sdk.

After all, I've got super frustrated with the developer experience as well as the unsatisfying result for the end-user. As an end-user, you'll get redirected to a domain of the auth provider, which does not just take a lot of time but could also result in trust issues for the user. Additionally, the login sdk adds a lot of extra code to your website, resulting in slower load times.

I've come to the conclusion that we can do better than that. Authentication should be very easy to set up, low maintenance and not distracting the user.

In the meantime, I've switched from using CRA to NextJS. I think it's a much more rounded developer experience including Server Side Rendering (SSR) etc... With CRA, I always had to figure out which dependencies to use in order to get started. NextJS might or might not be the best framework. For me, it makes enough decisions, so I can focus on what matters to me, my application and my users.

How I think, Authentication should work for Single Page Applications#

I'd like to outline how authentication should ideally be implemented. To make it easier to understand, I've divided the topic into two parts. One is looking at authentication from the end-user perspective. Equally important, the second part looks at the developer experience.

User Stories from the perspective of the end-user#

Let's start by defining a number of user-stories about how authentication should work for the end-user.

As a user, I don't want to be redirected on a different domain or website to be able to login to a web application.

The user experience is slow and confusing when you get redirected to a slightly different looking website or even a different domain. Multiple redirects take a lot of time. Users expect that high quality websites don't redirect them to different domains during the login flow. They might sound similar and also use the same colors, but the overall CSS is usually different. All this leads to confusion and might lead to users abandoning the login flow.

As a user, I don't want to see loading spinners on a website that requires authentication.

You've probably seen this funny dance many times before. You enter a website, no content, just redirects and loading spinners. Then after a few seconds the spinners disappear, and the login screen appears.

As a user, I want to immediately see the content if I'm already logged into a website.

Am I logged in or not? After the loading spinners, it shows the Login screen for a second or two. At first, you think you're logged out, but then out of a sudden, the login screen disappears, and the dashboard starts to show up. If I'm authenticated, why can't I immediately see the dashboard?

As a user, I want to do a single login for all subdomains of a website: app.example.com, blog.example.com, docs.example.com

Modern apps let you log in once across all products of a company. Why does it matter? Imagine you log into the dashboard of Stripe, then you open the docs. Wouldn't it make sense to immediately be logged in and show personalized docs? With single sign on across all products, you're able to deliver a much better user experience.

User Stories from the perspective of the developer#

For me, it's not just about the end-user experience. I want things to be easy to implement and maintain from a developer perspective as well. Therefore, I'll continue with a few stories for the devs.

As a developer, I want to add almost no code to my NextJS application for authentication to work.

I've outlined it above. I want to have almost nothing to do with authentication. It should just work. Every line of code added, every framework, library or npm package added is a liability. Someone has to maintain it, update it and fix security issues.

As a developer, I want to be able to log users into my application using one or more external authentication providers.

This should be the standard today. You should not add a database or custom authentication logic into your application. Also, I don't want to tightly couple authentication to an app instance.

NextAuthJS, while looking great, adds authentication directly to your application. We want authentication that works across multiple applications and subdomains, while adding minimal to no code to our codebase to not distract us.

You might disagree with me on this one, but I believe that you should not embed authentication into your application directly. Instead, you should have some kind of Authentication Gateway to handle the complexity while keeping your application clean.

If you want a centralised database to store information about your users, consider using an OpenID Connect SaaS or an On Premises OIDC implementation like Keycloak.

As a developer, I want authentication aware data fetching

At the same time I'm creating a Query or Mutation, I'd also want to create a policy if this operation is publicly available or if the user needs to be authenticated. If the user needs to be authenticated (private) and actually is, I want the operation to kick off. If the operation doesn't require authentication (public), the operation should immediately run. I don't want to add any extra logic into the frontend to handle all these cases, it's code duplication because it was already defined in the backend.

As a developer, I want to be able to implement SSR (Server Side Rendering) for authenticated users without much effort.

I want to get rid of those loading spinners, and the weird redirect dance. If the user is logged in already, I want to show them their dashboard right away.

As a developer, I don't want to mess around with differences between browsers.

Looking at the previous user stories, you might already anticipate that we need to work with cookies in order to achieve our goals. If you've ever worked with cookies and multiple domains, you should be aware that getting it to work on Chrome, Firefox and Safari at the same time is not easy. You have to deal with CORS and SameSite policies.

As a developer, I want an authentication solution that is secure out of the box but also easy to handle.

Using cookies means we'll get some convenience at the expense of a threat: CSRF (Cross Site Request Forgery). The solution must solve CSRF automatically. That is, it should prevent that authenticated users can be tricked into making and action which they didn't intend to do.

Implementing Authentication for Single Page Applications, the right way#

With the user stories set up, we're now able to define the architecture of our solution. We'll go through the stories one by one, discuss possible solutions as well as their pros and cons.

Single Page Applications should not redirect users to a different domain during the login flow#

If you try to log into a Google product, you'll get redirected to accounts.google.com. It's a subdomain but still belongs to google.com and therefore there are no trust issues with this approach.

Imagine your company domain is example.com and you redirect your users to example.authprovider.com. Even though there's example in the domain name, it's still confusing.

Ideally, we could run a service on e.g. accounts.example.com that handles the login for us. Running such a service would mean we have to run an extra service which needs to be maintained. At the same time, this service would give us full control over the look and feel which increases trust for the user. So, while this options sounds like a good approach for a company like Google, we might want to find something simpler.

What about a headless API service that also comes with authentication embedded? Luckily, there are protocols like OpenID Connect (OIDC) that allow us to run OIDC clients within our headless API service. This service will run on the domain api.example.com. The login flow will be initiated from example.com which also hosts the user interface to allow our users to start the flow. If the user clicks e.g. "Login with Google", we'll redirect them to api.example.com/login-with-google (not the real path) and handle the login flow from there. Once the flow is complete, we'll redirect the user back to example.com.

How does example.com know if the authentication was successful? Browsers are actually really cool about cookies. You're allowed to set cookies to the domain example.com while you're on the domain api.example.com. That is, the headless API service is allowed to set cookies for the whole example.com domain. Btw. this doesn't work the other way around. You're not able to set cookies for foo.example.com while you're on example.com or bar.example.com.

What does all this mean? We start the user login flow from example.com. On this page, we can fully own our user interface. We will then redirect to api.example.com and handle the login flow from there. Once complete, we can set a cookie to the whole example.com domain, allowing our users to be logged in to all our products! If we run a blog on blog.example.com or docs on docs.example.com we'd still have access to the user's cookie. By doing so, we've automatically achieved single sign on (SSO) for all our web applications.

Is this complicated to set up? I've outlined the steps involved, so there's definitely some work to be done.

If you're using WunderGraph, we've implemented this already for you. We're the headless API service mentioned above. Just deploy your apps to all subdomains you want, we'll provide api.example.dom for you. The setup takes just a few minutes.

First user story implemented, well almost. What about the tradeoffs? We've introduced cookies which means, we've created new security problems to solve, namely cross site request forgery (CSRF). For now, we don't want to solve the problem yet, just keep in mind that we have to tackle it later.

Users don't want to see loading spinners while the web application is figuring out if the user is authenticated or not#

As we've already covered a lot of ground with the last user story, this is going to be a lot easier. We've learned in the last story that we're able to set a cookie for the user on the whole example.com domain. This means, we're able to access this cookie via server-side rendering and don't have to rely on the client trying to figure out if the user is authenticated or not. The result is that there are no redirects at all and all the loading spinners are gone. The server-side rendering process knows if the client is authenticated and can inject some data to tell the JS client about it.

This makes for a super smooth user experience. The "time to usable application" is reduced by multiple seconds (redirects take time) and you can easily "refresh" the page.

Users want to see content immediately if they're authenticated#

More often than not, when you refresh a single page application (SPA) you're automatically reset to the landing page or even worse, the website logs you out automatically. With the cookie approach, the server can pre-render the website for individual users because it knows if the user is logged in and what their userID is.

Metrics like "time to interactive" are meaningless if you're not able to do anything with the application. What's important to the user is "time to usable application", the time until the authenticated user can see real content and can interact with it. With a cookie set across all subdomains, we're able to reduce this metric across all our applications to a minimum.

Users want to single sign on (SSO) into all our products#

We've already covered this one in the first user story. Due to the nature of browsers, we're able to set cookies across all subdomains. SSO is already solved, no expensive enterprise tooling required. All you have to do is run all your apps on subdomains of example.com which should be feasible.

The Developer Experience of implementing Authentication#

With that we've already covered all stories directly related to the end-users. Next, let's focus on the developer experience.

We don't want to too much extra code, frameworks, etc. to our NextJS application just to implement authentication#

As previously mentioned, the majority of the authentication code runs on api.example.com which you don't have to maintain. Because our api service is headless, we'll still have to build a login screen but this shouldn't take too much effort. All the login flows are being handled by api.example.com so all we have to do is build the login forms and delegate to the headless service.

If the user is authenticated, we can always use their cookie and forward it to api.example.com, even from server side rendering. This way, we don't even have to put any logic into our NextJS /api routes because authentication will be handled by passing on the cookie to the headless API service. The API service also comes with a handy endpoint to check if the user is logged in: .../user If you pass along the cookies from the user, this endpoint will return all claims for the current user, allowing you to server-render the website for them automatically or redirect them to the login page.

So, no additional code on the backend side of NextJS. In the client, we add very little logic for our login form so that we can redirect the user to the headless API service if they start the login flow.

Luckily, WunderGraph automatically generates this client for you. Assuming you've added GitHub as authentication service to your app, we'll generate a function on the client to initiate this login flow, making it as convenient as possible for you.

It should be easy to configure multiple authentication providers for our application#

I've seen it too many times that frameworks make authentication more complicated than it should be. Imagine you'd like to build an app that allows your users to log in with their GitHub account. There should be a way to securely store the client ID and secret for your GitHub application. Then, you should be able to call a login.github() function in your frontend and call it a day.

This is exactly how we've implemented the developer workflow. You can read more on the topic in the docs.

Here's an example config:

authentication: {
cookieBased: {
providers: [
authProviders.github({
id: "github",
"clientId": process.env.GITHUB_CLIENT_ID!,
"clientSecret": process.env.GITHUB_CLIENT_SECRET!,
}),
]
}
}

The WunderGraph code generator creates a fully typesafe client for us. This client can read the current user and allows us to start the login flow or log out the user.

const IndexPage: NextPage = () => {
const {client: {login, logout}, user} = useWunderGraph();
return (
<div>
<p>
{user === undefined && "user not logged in!"}
{user !== undefined && `name: ${user.name}, email: ${user.email}`}
</p>
<p>
{user === undefined && <button onClick={() => login.github()}>login</button>}
{user !== undefined && <button onClick={() => logout()}>logout</button>}
</p>
</div>
)
}

There are some very powerful details hidden in the example. If you look closely to the config, you'll see the "id" of the GitHub auth provider is "github". This id is re-used to generate the client and gives you the login.github() method.

As you can see, authentication can be simple and shouldn't take too much effort.

How to do authentication-aware data-fetching#

As outlined in the user story, the data fetching layer should be aware of authentication. As a backend developer, you always know if an operation requires authentication or not. Therefore, you could mark operations as public or private.

If an operation is public, any (anonymous) user could run it. If an operation is private, the user must be authenticated.

Most if not all API clients are not aware of this situation, therefore the frontend developer has to write custom code that checks if the user is authenticated if this is a requirement. They'll then manually inject the user credentials into the request.

This is completely unnecessary. Once we define a GraphQL operation, it's already clear that this operation is public or private. Let's look at an example to showcase this:

mutation (
$email: String! @fromClaim(name: EMAIL)
$name: String! @fromClaim(name: NAME)
$message: String!
) {
createOnemessages(data: {message: $message users: {connectOrCreate: {create: {name: $name email: $email} where: {email: $email}}}}){
id
message
}
}

This is a mutation from our realtime chat example. As you can see, we're injecting two claims into the mutation. This is a special feature of WunderGraph, it allows you to use information from the user's claims as variables for your Operations. As WunderGraph keeps GraphQL Operations entirely on the server, this is completely safe.

If you're using the @fromClaim directive, you're automatically marking the Operation as "private", meaning that authentication is required.

At this point we know about the operation as well as that authentication is required for the user to execute it. So far, this is similar to most approaches, even if you don't use WunderGraph.

The problem is that most frameworks drop this piece of information and ignore it. Code-Generators could integrate this piece if information to generate a smart client that is aware of the requirement for the user to be authenticated.

To achieve our goal, the WunderGraph code generator takes all the information about the Operations and generates a very smart client.

All you have to do is call the generated React Hook like so:

const {mutate: addMessage, response: messageAdded} = useMutation.AddMessage({refetchMountedQueriesOnSuccess: true});

If the user is authenticated, addMessage() will immediately run. If the user is unauthenticated, messageAdded will stay in the state of requiresAuthentication. Additionally, once the operation is successful refetchMountedQueriesOnSuccess will refetch all currently mounted queries. This is very handy as you don't have to manually update a client side cache.

This is how a client should handle data fetching, right?

Implementing Server Side Rendering (SSR) for authenticated users#

Now, onto another important topic: Server Side Rendering!

Ideally, we should be able to render the UI on the server, even for authenticated users. We've talked about the topic above already, so the goal is clear. However, it's not just about achieving the goal but also keeping things simple.

As previously discussed, we're using a headless API service that handles authentication via cookies. We also mentioned that we can set cookies across all subdomains (e.g. docs.example.com) if we set the domain of the cookie to the apex domain (example.com).

Alright, how does this look like from the developers perspective?

export const getServerSideProps: GetServerSideProps<Props> = async (context) => {
// for SSR, simply create a new client and pass on the cookie header from the client context
// this way, we can make authenticated requests via SSR
const client = new Client({
extraHeaders: {
cookie: context.req.headers.cookie,
}
});
// fetch the user so that we can render the UI based on the user name and email
const user = await client.fetchUser();
// fetch the initial messages
const messages = await client.query.Messages({});
return {
props: {
// pass on the data to the page renderer
user,
messages: messages.status === "ok" && messages.body.data.findManymessages.reverse(),
}
}
}

As you can see, it's quite simple. Create a client, pass on the cookie header from the user, fetch some data and pass it on to the page render function.

Authentication that actually works in all major browsers#

Getting cookie-based authentication to work might sound easy. However, it's actually non-trivial to make it both functional and secure across all major browsers can definitely be a pain. We've made sure that cookies are encrypted as well as http only. The SameSite config is as restrictive as possible.

Don't worry if you're not familiar with all these terms. We've built our solution based on OWASP best practices so that you don't have to.

CSRF protection out of the box#

If you're familiar with OWASP, you've probably heard of Cross Site Request Forgery (CSRF). Cookie-based authentication can be very powerful but also dangerous if you don't handle CSRF properly. If the browser always sends the user's auth information with every request, how does the server know if it was the intention of the user to make that request?

An attacker could trick the user into clicking a link which triggers an action, e.g. sending money to a bitcoin wallet, even if the user didn't want to send any money at all.

What can we do about this?

There are plenty of resources on the topic, so I'd try to keep it brief. Instead of a GET request, we always make a POST for mutations. Additionally, we'll transfer a csrf token from server to client and set a special csrf cookie. Once the csrf token is transferred to the client, the client will use it for all POST requests with a different header. This means, an attacker can't just create a URL and have the user click on it.

If you don't want to implement this yourself, just use WunderGraph. We automatically generate a client that takes care of CSRF protection out of the box. Nothing needs to be done on your side.

You can actually see all this in action in one of our demos. This is a Chat Application example. If you Log in and try to send your very first message, you'll see a /csrf call in the network tab, that's the exchange of the csrf token.

Summary & Demo#

I know it was a lot of content to digest. At the same time, isn't the end result what both end-users and developers are asking for? Applications that require authentication should be easy to use, fast and secure while being easy to implement and maintain for the developers.

I really hope we're able to contribute to this goal, making applications better while saving developers a lot of time.

If you want to see all the practices described in action, clone the demo and try it out yourself!

I'd be more than happy to hear your feedback!

About the Author
Jens Neuse

Jens Neuse

Jens has experience in building native apps for iOS and Android, built hybrid apps with Xamarin, React Native and Flutter, worked on backends using PHP, Java and Go. He's been in roles ranging from development to architecture and led smaller and larger engineering teams.

Throughout his whole career he realized that working with APIs is way too complicated, repetitive and needs a lot more standardization and automation. That's why he started WunderGraph, to make usage of APIs and collaboration through APIs easier.

He believes that businesses of the future will be built on top of collaborative systems that are connected through APIs. Making usage, exploration, sharing and collaboration with and through APIs easier is key to achieve this goal.

Follow and connect with Jens to exchange ideas or simply participate in his feed of thoughts.

Comments

Product

Subscribe to our newsletter!

Stay informed when great things happen! Get the latest news about APIs, GraphQL and more straight into your mailbox.

© 2021 WunderGraph