The Ultimate Guide to handling JWTs on frontend clients (GraphQL)
Table of Contents
- Table of Contents
- Introduction: What is a JWT?
- Basics: Login
- Basics: Client setup
- Basics: Logout
- Silent refresh
- Persisting sessions
- Force logout, aka Logout of all sessions/devices
- Server side rendering (SSR)
- Code from this blogpost (finished application)
- Try it out!
- References
- Summary
- Changelog
Introduction: What is a JWT?
Security Considerations
JWT Structure
Basics: Login
async function handleSubmit () {
//...
// Make the login API call
const response = await fetch(`/auth/login`, {
method: 'POST',
body: JSON.stringify({ username, password })
})
//...
// Extract the JWT from the response
const { jwt_token } = await response.json()
//...
// Do something the token in the login method
await login({ jwt_token })
}
import { login } from '../utils/auth'
await login({ jwt_token })
- Automatically sent by the browser (Cookie storage).
- Retrieved even if the browser is restarted (Use of browser localStorage container).
- Retrieved in case of XSS issue (Cookie accessible to JavaScript code or Token stored in browser local/session storage).
- Store the token using the browser
sessionStorage
container. - Add it as a Bearer HTTP
Authentication
header with JavaScript when calling services. - Add
fingerprint
information to the token.
- A random string that will be generated during the authentication phase. It will be sent to the client as an hardened cookie (flags:
HttpOnly
+Secure
+SameSite
+ cookie prefixes). - A SHA256 hash of the random string will be stored in the token (instead of the raw value) in order to prevent any XSS issues allowing the attacker to read the random string value and setting the expected cookie.
// Short duration JWT token (5-10 min)
export function getJwtToken() {
return sessionStorage.getItem("jwt")
}
export function setJwtToken(token) {
sessionStorage.setItem("jwt", token)
}
// Longer duration refresh token (30-60 min)
export function getRefreshToken() {
return sessionStorage.getItem("refreshToken")
}
export function setRefreshToken(token) {
sessionStorage.setItem("refreshToken", token)
}
function handleLogin({ email, password }) {
// Call login method in API
// The server handler is responsible for setting user fingerprint cookie during this as well
const { jwtToken, refreshToken } = await login({ email, password })
setJwtToken(jwtToken)
setRefreshToken(refreshToken)
// If you like, you may redirect the user now
Router.push("/some-url")
}
- Using in our API client to pass it as a header to every API call
- Check if a user is logged in by seeing if the JWT variable is set.
- Optionally, we can even decode the JWT on the client to access data in the payload. Let's say we need the user-id or the username on the client, which we can extract from the JWT.
const jwtToken = getJwtToken();
if (!jwtToken) {
Router.push('/login')
}
Basics: Client setup
import { useMemo } from "react"
import { ApolloClient, InMemoryCache, HttpLink, ApolloLink, Operation } from "@apollo/client"
import { getMainDefinition } from "@apollo/client/utilities"
import { WebSocketLink } from "@apollo/client/link/ws"
import merge from "deepmerge"
let apolloClient
function getHeaders() {
const headers = {} as HeadersInit
const token = getJwtToken()
if (token) headers["Authorization"] = `Bearer ${token}`
return headers
}
function operationIsSubscription(operation: Operation): boolean {
const definition = getMainDefinition(operation.query)
const isSubscription = definition.kind === "OperationDefinition" && definition.operation === "subscription"
return isSubscription
}
let wsLink
function getOrCreateWebsocketLink() {
wsLink ??= new WebSocketLink({
uri: process.env["NEXT_PUBLIC_HASURA_ENDPOINT"].replace("http", "ws").replace("https", "wss"),
options: {
reconnect: true,
timeout: 30000,
connectionParams: () => {
return { headers: getHeaders() }
},
},
})
return wsLink
}
function createLink() {
const httpLink = new HttpLink({
uri: process.env["NEXT_PUBLIC_HASURA_ENDPOINT"],
credentials: "include",
})
const authLink = new ApolloLink((operation, forward) => {
operation.setContext(({ headers = {} }) => ({
headers: {
...headers,
...getHeaders(),
},
}))
return forward(operation)
})
if (typeof window !== "undefined") {
return ApolloLink.from([
authLink,
// Use "getOrCreateWebsocketLink" to init WS lazily
// otherwise WS connection will be created + used even if using "query"
ApolloLink.split(operationIsSubscription, getOrCreateWebsocketLink, httpLink),
])
} else {
return ApolloLink.from([authLink, httpLink])
}
}
function createApolloClient() {
return new ApolloClient({
ssrMode: typeof window === "undefined",
link: createLink(),
cache: new InMemoryCache(),
})
}
export function initializeApollo(initialState = null) {
const _apolloClient = apolloClient ?? createApolloClient()
// If your page has Next.js data fetching methods that use Apollo Client, the initial state
// get hydrated here
if (initialState) {
// Get existing cache, loaded during client side data fetching
const existingCache = _apolloClient.extract()
// Merge the existing cache into data passed from getStaticProps/getServerSideProps
const data = merge(initialState, existingCache)
// Restore the cache with the merged data
_apolloClient.cache.restore(data)
}
// For SSG and SSR always create a new Apollo Client
if (typeof window === "undefined") return _apolloClient
// Create the Apollo Client once in the client
if (!apolloClient) apolloClient = _apolloClient
return _apolloClient
}
export function useApollo(initialState) {
const store = useMemo(() => initializeApollo(initialState), [initialState])
return store
}
else {
Router.push('/login')
}
import { onError } from 'apollo-link-error';
const logoutLink = onError(({ networkError }) => {
if (networkError.statusCode === 401) logout();
})
if (typeof window !== "undefined") {
return ApolloLink.from([
logoutLink,
authLink,
ApolloLink.split(operationIsSubscription, getOrCreateWebsocketLink, httpLink),
])
} else {
return ApolloLink.from([
logoutLink,
authLink,
httpLink
])
}
Basics: Logout
window.addEventListener('storage', this.syncLogout)
//....
syncLogout (event) {
if (event.key === 'logout') {
console.log('logged out from storage!')
Router.push('/login')
}
}
- Nullify the token
- Set
logout
item in local storage
import { useEffect } from "react"
import { useRouter } from "next/router"
import { gql, useMutation, useApolloClient } from "@apollo/client"
import { setJwtToken, setRefreshToken } from "../lib/auth"
const SignOutMutation = gql`
mutation SignOutMutation {
signout {
ok
}
}
`
function SignOut() {
const client = useApolloClient()
const router = useRouter()
const [signOut] = useMutation(SignOutMutation)
useEffect(() => {
// Clear the JWT and refresh token so that Apollo doesn't try to use them
setJwtToken("")
setRefreshToken("")
// Tell Apollo to reset the store
// Finally, redirect the user to the home page
signOut().then(() => {
// to support logging out from all windows
window.localStorage.setItem('logout', Date.now())
client.resetStore().then(() => {
router.push("/signin")
})
})
}, [signOut, router, client])
return <p>Signing out...</p>
}
Silent refresh
- Given our short expiry times on the JWTs, the user will be logged out every 15 minutes. This would be a fairly terrible experience. Ideally, we'd probably want our user to be logged in for a long time.
- If a user closes their app and opens it again, they'll need to login again. Their session is not persisted because we're not saving the JWT token on the client anywhere.
- It can be used to make an API call (say,
/refresh_token
) to fetch a new JWT token before the previous JWT expires. - It can be safely persisted across sessions on the client!
- Automatically sent by the browser (Cookie storage).
- Retrieved even if the browser is restarted (Use of browser localStorage container).
- Retrieved in case of XSS issue (Cookie accessible to JavaScript code or Token stored in browser local/session storage).
- Store the token using the browser
sessionStorage
container. - Add it as a Bearer HTTP
Authentication
header with JavaScript when calling services. - Add
fingerprint
information to the token.
- The user logs in with a login API call.
- Server generates JWT token and
refresh_token
, and afingerprint
- The server returns the JWT token, refresh token, and a
SHA256
-hashed version of the fingerprint in the token claims - The un-hashed version of the generated fingerprint is stored as a hardened,
HttpOnly
cookie on the client - When the JWT token expires, a silent refresh will happen. This is where the client calls the
/refresh
token endpoint
- The refresh endpoint must check for the existence of the fingerprint cookie, and validate that the comparison of the hashed value in the token claims is identical to the unhashed value in the cookie
- If either of these conditions are not met, the refresh request is rejected
- Otherwise the refresh token is accepted, and a fresh JWT access token is granted, resetting the silent refresh process
import { TokenRefreshLink } from "apollo-link-token-refresh"
import { JwtPayload } from "jwt-decode"
import { getJwtToken, getRefreshToken, setJwtToken } from "./auth"
import decodeJWT from "jwt-decode"
export function makeTokenRefreshLink() {
return new TokenRefreshLink({
// Indicates the current state of access token expiration
// If token not yet expired or user doesn't have a token (guest) true should be returned
isTokenValidOrUndefined: () => {
const token = getJwtToken()
// If there is no token, the user is not logged in
// We return true here, because there is no need to refresh the token
if (!token) return true
// Otherwise, we check if the token is expired
const claims: JwtPayload = decodeJWT(token)
const expirationTimeInSeconds = claims.exp * 1000
const now = new Date()
const isValid = expirationTimeInSeconds >= now.getTime()
// Return true if the token is still valid, otherwise false and trigger a token refresh
return isValid
},
// Responsible for fetching refresh token
fetchAccessToken: async () => {
const jwt = decodeJWT(getJwtToken())
const refreshToken = getRefreshToken()
const fingerprintHash = jwt?.["https://hasura.io/jwt/claims"]?.["X-User-Fingerprint"]
const request = await fetch(process.env["NEXT_PUBLIC_HASURA_ENDPOINT"], {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
query: `
query RefreshJwtToken($refreshToken: String!, $fingerprintHash: String!) {
refreshJwtToken(refreshToken: $refreshToken, fingerprintHash: $fingerprintHash) {
jwt
}
}
`,
variables: {
refreshToken,
fingerprintHash,
},
}),
})
return request.json()
},
// Callback which receives a fresh token from Response.
// From here we can save token to the storage
handleFetch: (accessToken) => {
setJwtToken(accessToken)
},
handleResponse: (operation, accessTokenField) => (response) => {
// here you can parse response, handle errors, prepare returned token to
// further operations
// returned object should be like this:
// {
// access_token: 'token string here'
// }
return { access_token: response.refreshToken.jwt }
},
handleError: (err) => {
console.warn("Your refresh token is invalid. Try to reauthenticate.")
console.error(err)
// Remove invalid tokens
localStorage.removeItem("jwt")
localStorage.removeItem("refreshToken")
},
})
}
if (!sessionStorage.length) {
// Ask other tabs for session storage
localStorage.setItem("getSessionStorage", String(Date.now()))
}
window.addEventListener("storage", (event) => {
if (event.key == "getSessionStorage") {
// Some tab asked for the sessionStorage -> send it
localStorage.setItem("sessionStorage", JSON.stringify(sessionStorage))
localStorage.removeItem("sessionStorage")
} else if (event.key == "sessionStorage" && !sessionStorage.length) {
// sessionStorage is empty -> fill it
const data = JSON.parse(event.newValue)
for (let key in data) {
sessionStorage.setItem(key, data[key])
}
}
})
Persisting sessions
Force logout, aka Logout of all sessions/devices
Server side rendering (SSR)
- The browser makes a request to a app URL
- The SSR server renders the page based on the user's identity
- The user gets the rendered page and then continues using the app as an SPA (single page app)
How does the SSR server know if the user is logged in?
- Upon receiving a request to render a particular page, the SSR server captures the refresh_token cookie.
- The SSR server uses the refresh_token cookie to get a new JWT for the user
- The SSR server uses the new JWT token and makes all the authenticated GraphQL requests to fetch the right data
Code from this blogpost (finished application)
Try it out!
References
- JWT.io
- OWASP notes on XSS, CSRF and similar things
- The Parts of JWT Security Nobody Talks About | Philippe De Ryck
Lots of other awesome stuff on the web
Summary
Changelog
- (12/28/2021) Recommendation to store token in Cookie changed to
sessionStorage
, per OWASP JWT guidelines to addressIssue: Token Storage on Client Side
0 - (12/28/2021) Adopted OWASP Application Security Verification Standard v5 6 L1-L2 guidelines
- Of note: Chapters 3 (
Session Management
) and 8 (Data Protection
)
- Of note: Chapters 3 (
- (12/28/2021) Alter section on
Persisting Sessions
to contain OWASP guidelines on this - (12/28/2021) Sample application repo code updated 1
- Update from Next.js 9 -> 12, update
@apollo
libraries tov3.x
- Password hashing algorithm changed from
bcrypt
to native Node.jscrypto.scrypt
per OWASP guidelines 2 and to reduce number of external dependencies - Authentication on frontend and backend modified to make use of a user fingerprint in addition to a token, per OWASP guidelines on preventing
Token Sidejacking
3 - Example usage of
TokenRefreshLink
4 to manage silent refresh workflow added - Server endpoints integrated through Hasura Actions 5 rather than directly invoking from client
- Adopt recommended use of
crypto.timingSafeEqual()
to prevent timing attacks
- Update from Next.js 9 -> 12, update
Related reading