This article provides an example of using http only cookies as containers for refresh tokens while sending authorization tokens as response to the client stored in memory on the client side.
In this article i will be using graphql, a more flexible api query language and typescript a strongly typed superset of javascript and mongodb ,a nosql database.
What are refresh tokens and why do we need them?
Refresh tokens are JWT that are long-lived and contains enough information about the user to generate access tokens. They are long lived in the sense that their expiration date is longer when compared to access tokens. Since JWT are stateless there is no way to destroy them until the they expire. Hence for better security access tokens used to access authenticated routes should have a short expiration period.
Just before an access token expires however a refresh token carries out a silent refresh to generate another access token so the user does not get forcefully logged out and have to login again.
In using refresh tokens, however the consideration mentioned in the first article of this series should be taken into account.
Remaining Stateless - Using Redis for token blacklisting in Node JS
Ogbonna Basil ・ Jul 22 '19
Where to store Refresh and access tokens?
Since we want to remain stateless, not saving the user state in any database, refresh tokens are generated on the backend and saved in the headers of the request using a http only cookie. using a http only ensures that that the client does not have access to cookies in the headers. For additional security you can add the secure option when creating the cookies to be true. This would ensure that you can only make request from https.
On the other hand the access tokens are best saved in memory on the frontend. That way they are not exposed to XSS attacks associated with local storage or CSRF attacks associated with cookie storage.
If refresh tokens are saved with cookies does that not make them susceptible to CSRF attacks?
Cookie storage are susceptible to CSRF attacks but if an attacker get access to your refresh token via a form attack, the attacker cannot gain access to authenticated routes because he maybe able to generate access tokens from the refresh tokens but would not be able to access them because they are saved in memory.
The example below shows a simple user authentication with refresh token and access tokens.
- Create a database connection in mongodb
import dotenv from "dotenv";
import mongoose from "mongoose";
dotenv.config();
const url = process.env.MONGO_URI || "mongodb://localhost:27017/users";
export default function db() {
mongoose.connect( url, { useCreateIndex: true,
useNewUrlParser: true, useUnifiedTopology: true,
}).catch((err) => console.log(err));
}
// dbConfig.ts
- Create a model for user including it type implementation using interfaces
import bcrypt from "bcryptjs";
import mongoose, { Document, Schema } from "mongoose";
export interface IUser extends Document {
email: string;
username: string;
password: string;
}
const userSchema: Schema = new Schema({
email: { type: String, required: true, unique: true , sparse: true },
username: { type: String, required: true },
password: { type: String, required: true },
});
userSchema.pre<IUser>("save", function(next) {
if (!this.isModified("password")) { return next(); }
const hash = bcrypt.hashSync(this.password, 10);
this.password = hash;
return next();
});
// method for compare the password
userSchema.methods.comparePassword = function(password: string) {
const user = bcrypt.compareSync(password, this.password);
return user ? this : null;
};
export default mongoose.model<IUser>("user", userSchema);
// model.ts
- In the example below i am using graphql-yoga, a graphql implementation built on top Apollo graphql server
import { ContextParameters } from "graphql-yoga/dist/types";
import models from "path/to/models";
export default function({request, response}: ContextParameters) {
return {
models,
request,
response,
};
}
// context.ts
- Type definition in graphql that describe the inputs and expected ouptut for either mutations, query or subscriptions
const typeDefs =`
type Query {
refresh(id: ID!): String!
}
type Mutation {
login(input: loginDetails): Auth!
signup(input: signupDetails): Auth!
doSomething(input: someInput) : String!
}
type Auth {
user: User!
token: String!
}
type User {
id: ID!
username: String!
email: String!
}
input signupDetails{
email: String!
username: String!
password: String!
}
input loginDetails{
email: String
password: String
}
input someInput {
something: String
}
`
export default typeDefs;
// typeDef.ts
- In the code below on signup, a refresh token is generated and saved in the http only cookie via the method auth.generateRefreshToken. Also the access token is generated through the auth.generateAccessToken method. This also happens on login.
The refresh resolver gets the refresh token from the cookie string, verifies it and uses it to generate a new access token. The client has to make frequent calls to this mutation so as to ensure the user will not be forced out once the access token expires. Also notice that on refresh it generates an refreshCookie. Thus the previous refresh cookie is updated and you have a new cookie that has a expiry span of 30 days from when you last called the refresh token query. That way a user can always be logged in as far he is active over the last say 30 days.
The doSomething resolver verifies the access token sent as authorization header and then allows user access to authenticated routes based on it.
import { Context } from "graphql-yoga/dist/types";
import helpers from "path/to/utils";
const { auth, secret } = helpers;
export const signup = async (parent: any, args: any, { models, response }: Context) => {
try {
const userEmailExists = await models.user.findOne({ email: args.input.email });
if (userEmailExists) {
throw new Error("Email already exists");
}
const user = await models.user.create(args.input);
auth.generateRefreshCookie({id: user.id}, response);
const token = auth.generateAccessToken({ id: user.id });
return { user, token };
} catch (err) {
throw new Error(err.toString());
}
};
export const login = async (parent: any, args: any, { models, request, response }: Context) => {
try {
const user = await models.user.findOne({ email: args.input.email });
if (!user || !user.comparePassword(args.input.password)) {
throw new Error("Invalid user login details");
}
auth.generateRefreshCookie({ id: user.id}, response,
);
const token = auth.generateAccessToken({ id: user.id });
return { user, token };
} catch (err) {
throw new Error(err.toString());
}
};
export const refresh = async (parent: any, args: any, { request, response }: Context) => {
try {
const tokenString = request.headers.cookies.split(";")[0];
const currentRefreshToken = tokenString.split("=")[1];
if (!currentRefreshToken) {
throw new Error("No Refresh Token found");
}
const decoded = auth.decode(currentRefreshToken, secret.refreshSecret);
const devices = auth.decode(decoded.address, secret.userSecret);
await auth.generateRefreshCookie({id: user.id}, response,)
return auth.generateAccessToken({ id: decoded.id });
} catch (err) {
throw new Error(err.toString());
}
};
export const doSomething = async (parent: any, args: any, { request }: Context) => {
try {
const userId = await auth.verifyToken(request)
// then do something on token verification
return 'something'
}
catch(err) {
throw new Error (err.toString())
}
}
// resolver.ts
import { Context } from "graphql-yoga/dist/types";
import * as auth from "path/to/helpers/auth";
import secret from "path/to/helpers/secret";
export default({
auth,
secret,
})
// utils.ts
import {config} from "dotenv";
import {Secret} from "jsonwebtoken";
config();
const secret = ({
appSecret : process.env.APP_SECRET as Secret,
refreshSecret: process.env.REFRESH_SECRET as Secret,
})
// secret.ts
- In the code below notice that for the generateAccessToken, the token expires in 15min whereas the refreshToken used in the generateCookie method expires in 30days. It therefore means that a user will be logged in for 30 days from the last time of being active, before been logged out that is, if the user does not deliberately log out within this time frame.
Note also that the httpOnly option in cookie is set to true. Client side javascript has no way to view this cookie and this adds additional security. If you wish to use it only via https then set secure to true.
import { Context } from "graphql-yoga/dist/types";
import jwt, { Secret } from "jsonwebtoken";
import secrets from "path/to/helpers/secret";
const { appSecret, refreshSecret } = secrets;
export const encode = (args: any, secret: Secret, options: object) => {
return jwt.sign(args, secret, options) as any;
};
export const decode = (args: any, secret: Secret) => {
const decoded = jwt.verify(args, secret) as any;
if (!decoded) {
throw new Error("Invalid Token");
}
return decoded;
};
export const generateAccessToken = (args: any) => {
const token = encode(args, appSecret, { expiresIn: "15m" });
return token;
};
export const generateRefreshCookie = (args: any, response: Context) => {
const refreshToken = encode(args, refreshSecret, { expiresIn: "30d" });
const auth = response.cookie("refreshtoken", refreshToken, {
expiresIn: "30d",
httpOnly: true,
secure: false,
});
return auth;
};
export const verifyToken = (request: Context) => {
const token = request.headers.authorization.split(" ")[1];
if (token) {
const decoded = decode(token, appSecret) as any;
return decoded;
}
throw new Error("Not Authenticated");
};
// auth.ts
- To be able to use use cookies you need a cookie parser , so install cookie-parser and use it as a middleware. Also in using cookies you need to set Cors credentials to be true and explicitly state the address from which the request will be originating. You
import parser from "body-parser";
import compression from "compression";
import cookieparser from "cookie-parser";
import cors from "cors";
import {config} from "dotenv";
import { NextFunction, Request, Response } from "express";
import {GraphQLServer} from "graphql-yoga"
config()
export const handleCors = (router: GraphQLServer) =>
router.use(cors({ credentials: true, origin: [`process.env.frontUrl`] }));
export const handleBodyRequestParsing = (router: GraphQLServer) => {
router.use(parser.urlencoded({ extended: true }));
router.use(parser.json());
};
export const handleCookieParsing = (router: GraphQLServer) => {
router.use(cookieparser());
};
export const handleCompression = (router: GraphQLServer) => {
router.use(compression());
};
}))
export default [handleCors, handleBodyRequestParsing, handleCookieParsing, handleCompression
];
// applyMiddleware
- Note that Graphql has an inbuilt method of handling middlewares which could be used instead of this approach
import { GraphQLServer } from "graphql-yoga";
import db from "path/to/dbConfig";
import context from "path/to/context";
import resolvers from "path/to/resolver";
import typeDefs from "path/to/typedefs";
import { applyMiddleware } from "path/to/applyMiddleware";
process.on("uncaughtException", (e) => {
console.error("uncaught exception ", e);
process.exit(1);
});
process.on("unhandledRejection", (e) => {
console.error("Unhandled Promise rejection ", e);
process.exit(1);
});
db();
const server = new GraphQLServer({
context,
resolvers,
typeDefs,
},
);
applyMiddleware(middleware, server);
const options = {
endpoint: "/users",
playground: "/",
port: 8000,
subscriptions: "/subscriptions",
};
server.start(options, ({port}) =>
console.log(`Server started, listening on port ${port} for incoming requests.`),
);
// server.ts
I will leave you to try this optimal method for authenticating users while remaining stateless.
Top comments (4)
This is a great write up! For additional security, I would recommend adding invalidation of the refresh token on refresh so that each token can only be used once. I usually store the refresh token in the DB on the server-side. You can even add the IP address(block) or the user agent and reject the token refresh if they don't match...
@ivomeissner is that more like blacklisting the previous refresh token when a new one is generated in redis or using browser finger printing to check the IP address on incoming requests?
Yeah, you can do both. I usually always store it in the DB, so that I can also revoke access by deleting the refresh token in the DB in case someone wants to change passwords (device stolen etc.). Otherwise, it's a lot harder to invalidate the refresh tokens and might have an impact on other users (for example invalidate all tokens that were issued before x).
Thank you