DEV Community

Cover image for How I Built Authentication in Go and React: Doing It Right
Prajwal Patil
Prajwal Patil

Posted on Originally published at prajwalpatil.com

How I Built Authentication in Go and React: Doing It Right

Every tech blog will tell you the same thing: don’t roll your own auth. It makes sense not to do that for production applications with a large number of users, where more is at stake, and to use something like Auth0, Clerk, or Better Auth. Since I had no such requirements, I thought of building one myself from scratch by following best practices.

I decided to build this auth system with React, Go, and PostgreSQL.

It was clear to me that before I started to build, I had to answer some questions to begin with -

  1. Do I want to verify every request by looking up my database to verify it is legit or not?
  2. Do I want strict control over a user's session, and would I want to be able to terminate or log out a user instantaneously when needed?

The answers to these questions would totally depend on the kind of application I was building. I did not have a specific use case in mind, so to keep it simple and straightforward, I decided to go with "no" to both of these questions. Answering "no" to both meant I didn't need a strict session control - which is exactly the trade-off JWTs make in exchange for statelessness. It was clear that I would be building an auth system based on JSON Web Tokens (JWTs).

JWTs are created by signing a JSON object (the payload/claims) using a private key. This object contains things like email, userId, token expiry, etc. A client cannot tamper with this object because it is signed using a secret key by the server, so any modification invalidates it. The object can be read by the server - or the client - without making any lookup queries to the database, since the payload itself is just base64.

If we used a simple sessionId instead of JWTs, every request would have to be looked up in the database to get the authenticated user's details, which would significantly increase database load.

A JWT by itself wouldn't be enough, because we don't want the server to lose control of authentication once a JWT is granted. An expiry is set on the JWT, typically about 15 minutes; it will be refreshed using a "refresh token," which is issued by the server and stored in the database. A single call to the database every 15 minutes to check if the refresh token is valid is a reasonable thing to do without any performance hit. The server has total control over the refresh token and can delete it for a user whenever it wants. Refresh tokens typically have an expiry measured in days, so I set mine to 15 days. I used absolute expiry just to keep it simple. There is also something called rolling expiry, where the expiry is extended based on recent usage.

The next question is: where can I store the refresh token and the JWT?

Before that, here's the database schema I used. I split auth (email, password hash, refresh tokens) from public (everything else about a user, like their display name) - keeping authentication data separate from the application data.

-- +goose Up
CREATE SCHEMA auth;

CREATE TABLE auth.users (
    id uuid DEFAULT uuidv7() PRIMARY KEY,
    email VARCHAR(255) NOT NULL UNIQUE,
    created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL
);

CREATE TABLE auth.passwords (
    id uuid REFERENCES auth.users(id) PRIMARY KEY,
    hashed_password VARCHAR(255) NOT NULL
);

CREATE TABLE auth.tokens (
    id uuid REFERENCES auth.users(id) NOT NULL,
    refresh_token VARCHAR(255) NOT NULL,
    expires_at TIMESTAMPTZ NOT NULL
);

CREATE TABLE public.users (
    id uuid REFERENCES auth.users(id) PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    is_active BOOLEAN DEFAULT true
);
Enter fullscreen mode Exit fullscreen mode

auth.tokens is what makes revocation possible - deleting a row there invalidates that refresh token and prevents the server from issuing any further JWTs, which is the whole point of not going fully stateless.

Storing the refresh token

Using localStorage is quite risky, because a malicious script running in your client-side JavaScript can get access to your refresh token just by doing -

localStorage.getItem("refreshToken");
Enter fullscreen mode Exit fullscreen mode

This can lead to an XSS (Cross-Site Scripting) attack, and it's not practical. localStorage also has no expiry date and is never cleared. sessionStorage is also not a good idea, because it clears when you close the tab, which is quite annoying to users.

Cookies are the best option, because when set with the HttpOnly flag, they cannot be read by any JavaScript running on the client side - not even by the JS of the application that set it. If cookies are set with the Secure flag, it allows transmission only over HTTPS, so your refresh token is safe from interception and MITM (Man in the Middle) attacks. Setting SameSite=Lax prevents CSRF (Cross-Site Request Forgery) attacks to some extent.

Cookies also have the advantage of being attached automatically to all requests made to the server endpoint that set them.

While there are still some sophisticated ways to carry out these attacks even after all these security measures, this cookie configuration prevents most of the common ones.

The cookie config I used in Go is quite straightforward:

func createRefreshCookie(token string) *http.Cookie {
    return &http.Cookie{
        Name:     "refreshToken",
        Value:    token,
        Path:     "/",
        HttpOnly: true,
        SameSite: http.SameSiteLaxMode,
        Secure:   true,
    }
}
Enter fullscreen mode Exit fullscreen mode

Storing the JWT

The JWT is commonly stored in memory as a variable in client-side JavaScript. It's completely fine that it gets destroyed on page reload, because a refresh call can be made to the server to get a new JWT. This provides CSRF protection by default. It still exposes us to XSS risk, but that risk is temporary, because JWTs are short-lived.

The JWT is attached as an Authorization header, like Authorization: Bearer <token>, in each request made to the server.

Setting up the backend

I used the net/http library in Go to create a server, and goose for migrations. sqlc was a good option for generating query functions from my queries.

In Go, I used golang-jwt for JWTs and bcrypt to hash passwords. Choosing jackc/pgx for creating PostgreSQL connection pools was quite an obvious choice. These library choices were almost the default ones anyone would make for doing that specific thing.

I passed errors like "email already exists" - which originates as a unique key violation error - as a value, using Go's feature of treating errors as values.

    id, err := qtx.CreateAuthUser(ctx, payload.Email)
    if err != nil {
        var pgErr *pgconn.PgError
        if errors.As(err, &pgErr) && pgErr.Code == "23505" {
            return false, fmt.Errorf("%w: %v", ErrEmailAlreadyExists, err)
        }
        return false, err
    }
Enter fullscreen mode Exit fullscreen mode

Passwords are hashed with bcrypt before they're stored, and compared the same way on login:

func hashPassword(password string) (string, error) {
    passBytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
    return string(passBytes), err
}

func isValidPassword(hashedPassword string, inputPassword string) bool {
    err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(inputPassword))
    if err != nil {
        return false
    }
    return true
}
Enter fullscreen mode Exit fullscreen mode

For the JWT itself, golang-jwt handles signing and parsing. Creating a token means building the claims struct and signing it with the server's key:

func createJWTString(id string, email string) (string, error) {
    claims := UserJWTClaims{
        ID:    id,
        Email: email,
        RegisteredClaims: jwt.RegisteredClaims{
            IssuedAt:  jwt.NewNumericDate(time.Now()),
            ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(JWT_EXPIRY_MINUTES) * time.Minute)),
        },
    }
    token := jwt.NewWithClaims(JWT_SIGNING_ALGO, claims)
    tokenString, err := token.SignedString(jwtSigningKey)
    if err != nil {
        return "", err
    }
    return tokenString, err
}
Enter fullscreen mode Exit fullscreen mode

Verifying one on the way in looks like this - parse it, confirm the signing method matches what's expected:

func parseJWTClaims(tokenString string, signingKey []byte) (*UserJWTClaims, error) {
    parsedToken, err := jwt.ParseWithClaims(tokenString, &UserJWTClaims{}, func(token *jwt.Token) (interface{}, error) {
        if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
            return nil, ErrInvalidJWT
        }
        return signingKey, nil
    })
    if err != nil {
        if errors.Is(err, jwt.ErrTokenExpired) {
            return nil, ErrExpiredJWT
        }
        return nil, err
    }
    claims, ok := parsedToken.Claims.(*UserJWTClaims)
    if !ok || !parsedToken.Valid {
        return nil, ErrInvalidJWT
    }
    return claims, nil
}
Enter fullscreen mode Exit fullscreen mode

That verification is wrapped in a middleware that any protected route can use - it reads the Authorization header, verifies the token, and attaches the claims to the request context so downstream handlers can access the authenticated user:

func authMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        authHeader := r.Header.Get("Authorization")
        if !strings.HasPrefix(authHeader, "Bearer ") {
            w.WriteHeader(http.StatusUnauthorized)
            return
        }
        authItems := strings.Split(authHeader, " ")
        if len(authItems) != 2 {
            w.WriteHeader(http.StatusUnauthorized)
            return
        }
        jwtString := authItems[1]
        claims, err := parseJWTClaims(jwtString, jwtSigningKey)
        if err != nil {
            w.WriteHeader(http.StatusUnauthorized)
            return
        }
        ctx := context.WithValue(r.Context(), CLAIMS_CONTEXT_KEY, claims)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}
Enter fullscreen mode Exit fullscreen mode

With hashing, signing, and middleware in place, the /login and /refresh endpoints tie it together. /login verifies credentials, issues a JWT, and sets the refresh token as a cookie:

mux.HandleFunc("POST /login", func(w http.ResponseWriter, r *http.Request) {
    queries := db.New(pool)
    var payload LoginRequest
    err := json.NewDecoder(r.Body).Decode(&payload)
    if err != nil {
        fmt.Println("JSON decode error", err)
        http.Error(w, "Invalid JSON payload", http.StatusBadRequest)
        return
    }
    defer r.Body.Close()
    payload.Email = normalizeEmail(payload.Email)
    existingRefreshCookie, _ := r.Cookie(REFRESH_TOKEN_COOKIE_NAME)
    loginResponse, err := loginUser(context.Background(), pool, queries, payload, existingRefreshCookie)
    if err != nil {
        if errors.Is(err, ErrInvalidPassword) {
            writeError(w, ErrInvalidPassword)
            return
        } else if errors.Is(err, ErrEmailDoesNotExist) {
            writeError(w, ErrEmailDoesNotExist)
            return
        }
        fmt.Println("Login error: ", err)
        w.WriteHeader(http.StatusInternalServerError)
        return
    }
    refreshCookie := createRefreshCookie(loginResponse.RefreshToken)
    http.SetCookie(w, refreshCookie)
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(loginResponse)
})
Enter fullscreen mode Exit fullscreen mode

And /refresh is what the axios interceptor (from the frontend) calls whenever a request comes back 401 - it reads the refresh token cookie, looks up the user it belongs to, and issues a fresh JWT:

mux.HandleFunc("POST /refresh", func(w http.ResponseWriter, r *http.Request) {
    queries := db.New(pool)
    refreshToken, err := r.Cookie(REFRESH_TOKEN_COOKIE_NAME)
    if err != nil {
        w.WriteHeader(http.StatusUnauthorized)
        return
    }
    user, err := getUserFromRefreshToken(context.Background(), queries, refreshToken.Value)
    if err != nil {
        w.WriteHeader(http.StatusUnauthorized)
        return
    }
    jwtString, err := createJWTString(user.ID, user.Email)
    if err != nil {
        w.WriteHeader(http.StatusInternalServerError)
        return
    }
    user.Token = jwtString
    json.NewEncoder(w).Encode(user)
})
Enter fullscreen mode Exit fullscreen mode

Simplifying the frontend

The frontend was a simple React + Vite app using shadcn/ui components.

I used zod to create schemas for my Login and Signup components, something like this:

const User = z
  .object({
    name: z
      .string()
      .min(1, "Name should not be empty")
      .min(2, "Name must be atleast two characters"),
    email: z.email({
      error: (issue) =>
        issue.input === ""
          ? "Email should not be empty"
          : "Invalid Email Address",
    }),
    password: z
      .string()
      .min(1, "Password should not be empty")
      .min(6, "Password should be atleast 6 characters"),
    confirmPassword: z.string(),
  })
  .refine((data) => data.password === data.confirmPassword, {
    error: "Passwords do not match",
    path: ["confirmPassword"],
  });
Enter fullscreen mode Exit fullscreen mode

I used react-hook-form with a zod resolver, which made handling errors, submission, success, and failure states easy within a form. Using useState for the whole form, or even for each field, was a big no from a performance standpoint - react-hook-form avoids re-rendering on every keystroke by keeping inputs uncontrolled.

const {
  register,
  setError,
  handleSubmit,
  formState: { errors },
} = useForm({
  resolver: zodResolver(User),
});
Enter fullscreen mode Exit fullscreen mode

I used axios to create an API object with interceptors for requests and responses.

For requests, an Authorization header with the JWT is attached to each call. The token is stored in memory. For responses, if a call fails with a 401, a refresh call is made. If the refresh call also fails, the user is redirected to the login page.

api.interceptors.request.use((config) => {
  const token = getToken();
  if (token) config.headers.set("Authorization", `Bearer ${token}`);
  return config;
});

api.interceptors.response.use(
  (config) => config,
  async (error) => {
    const config = error.config;
    const shouldRetry = error.response?.status === 401;
    if (!shouldRetry) {
      return Promise.reject(error);
    }
    if (config._retryCount >= MAX_RETRIES) {
      return Promise.reject(error);
    }
    config._retryCount = config._retryCount ? config._retryCount + 1 : 1;
    try {
      const response = await refresh();
      const data = response?.data as LoginResponse;
      config.headers.set("Authorization", `Bearer ${data.token}`);
      setToken(data.token);
      return api(config);
    } catch (error) {
      if (shouldNavigateToLoginPage()) router.navigate("/login");
      return Promise.reject(error);
    }
  },
);
Enter fullscreen mode Exit fullscreen mode

For routing, I used react-router, choosing its Data mode.

I initially considered the Declarative mode, but I needed to access the router object inside the axios interceptor functions. I couldn't use the navigate function from the useNavigate hook, because interceptors aren't inside a React component. Exporting the router object was possible in Data mode, so I chose that.

For creating a global auth context across the application, I used the default createContext and useContext hooks to build a custom hook called useAuth. I also added route guards across a protected route, like the profile page, and public routes, like the signup and login pages.

const publicRoutes: RouteObject[] = [
  {
    path: "/login",
    element: (
      <PublicRoute>
        <Login />
      </PublicRoute>
    ),
  },
  {
    path: "/signup",
    element: (
      <PublicRoute>
        <Signup />
      </PublicRoute>
    ),
  },
];

const protectedRoutes: RouteObject[] = [
  {
    path: "/",
    element: (
      <ProtectedRoute>
        <Profile />
      </ProtectedRoute>
    ),
  },
];

export const router = createBrowserRouter([
  ...protectedRoutes,
  ...publicRoutes,
]);
Enter fullscreen mode Exit fullscreen mode

By doing all this, I built a decent authentication system that I can reuse in my other projects.

Building it myself made me understand the reasoning behind each decision - why JWTs instead of session IDs, why refresh tokens are needed and why cookies are safer than localStorage.

Building it from scratch was totally worth it for understanding how authentication actually works.

You can check the full source code here: https://github.com/prajwalkpatil/roam-auth

Top comments (0)