B2B Saas Authentication

/

Quickstarts

/

Quickstarts

/

Go

Go Quickstart

This quickstart guide outlines the essential steps to build a Discovery sign-up and login flow in your Go app using Stytch's B2B SaaS Authentication product.

Overview

Stytch offers a Go SDK that can be used either stand-alone, for an entirely backend integration with Stytch, or alongside our frontend SDKs. This quide covers the steps for an entirely backend integration with Stytch.

Want to skip straight to the source code? Check out an example app here.

Getting Started

1
Install Stytch SDK and configure your API Keys

Create a Stytch B2B Project in your Stytch Dashboard if you haven't already.

Install our Go SDK

go get github.com/stytchauth/stytch-go/v15

Configure your Stytch Project's API keys as environment variables:

STYTCH_PROJECT_ID="YOUR_STYTCH_PROJECT_ID"
STYTCH_SECRET="YOUR_STYTCH_PROJECT_SECRET"
# Use your Project's 'test' or 'live' credentials

2
Set up your app and login route

Set up a basic service and initialize the Stytch client with the environment variables you set in the previous step. Register an HTTP handler for a /login route that takes in the user's email address and initiates the sign-up or login flow by calling Stytch.

package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"os"

	gorillaSessions "github.com/gorilla/sessions"
	"github.com/joho/godotenv"
	"github.com/stytchauth/stytch-go/v15/stytch/b2b/b2bstytchapi"
	discoveryOrgs "github.com/stytchauth/stytch-go/v15/stytch/b2b/discovery/organizations"
	"github.com/stytchauth/stytch-go/v15/stytch/b2b/magiclinks/discovery"
	emlDiscovery "github.com/stytchauth/stytch-go/v15/stytch/b2b/magiclinks/email/discovery"
)

var ctx = context.Background()
const sessionKey = "stytch_session_token"

func main() {
	// Load variables from .env file into the environment.
	if err := godotenv.Load(".env.local"); err != nil {
		log.Fatalf("Error loading .env file: %v", err)
	}

	// Instantiate a new API service.
	service := NewAuthService(
		os.Getenv("STYTCH_PROJECT_ID"),
		os.Getenv("STYTCH_SECRET"),
	)

	// Register HTTP handlers.
	mux := http.NewServeMux()
	mux.HandleFunc("/login", service.sendMagicLinkHandler)

	// Start server.
	server := http.Server{
		Addr:    ":3000",
		Handler: mux,
	}
	log.Println("WARNING: For testing purposes only. Not intended for production use...")
	log.Println("Starting server on http://localhost:3000")
	if err := server.ListenAndServe(); err != nil {
		log.Fatal(err)
	}
}

type AuthService struct {
	client *b2bstytchapi.API
	store  *gorillaSessions.CookieStore
}

func NewAuthService(projectId, secret string) *AuthService {
	client, err := b2bstytchapi.NewClient(projectId, secret)
	if err != nil {
		log.Fatalf("Error creating client: %v", err)
	}

	return &AuthService{
		client: client,
		store:  gorillaSessions.NewCookieStore([]byte("your-secret-key")),
	}
}

func (s *AuthService) sendMagicLinkHandler(w http.ResponseWriter, r *http.Request) {
	if err := r.ParseForm(); err != nil {
		log.Printf("Error parsing form: %v\n", err)
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	email := r.Form.Get("email")
	if email == "" {
		http.Error(w, "Email is required", http.StatusBadRequest)
		return
	}

	_, err := s.client.MagicLinks.Email.Discovery.Send(
		ctx,
		&emlDiscovery.SendParams{
			EmailAddress: email,
	})
    if err != nil {
        log.Printf("Error sending email: %v\n", err)
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

	w.WriteHeader(http.StatusOK)
    fmt.Fprintln(w, "Successfully sent magic link email!")
}

3
Add a route to handle redirect callback from Stytch

When a user completes an authentication flow in Stytch, we will call the Redirect URL specified in your Stytch Dashboard with a token used to securely complete the auth flow. By default the redirect URL is set tohttp://localhost:3000/authenticate.

You can read more about redirect URLs and possible token types in this guide.

import (
	// Add new imports
	"github.com/stytchauth/stytch-go/v15/stytch/b2b/magiclinks/discovery"
)

func main() {
	// Add new HTTP handler
	mux.HandleFunc("/authenticate", service.authenticateHandler)
}

func (s *AuthService) authenticateHandler(w http.ResponseWriter, r *http.Request) {
	tokenType := r.URL.Query().Get("stytch_token_type")
	token := r.URL.Query().Get("token")

    if tokenType != "discovery" {
        log.Printf("Error: unrecognized token type %s\n", tokenType)
	    http.Error(
			w,
			fmt.Sprintf("Unrecognized token type %s", tokenType), 
			http.StatusBadRequest,
		)
        return
    }

	resp, err := s.client.MagicLinks.Discovery.Authenticate(
		ctx, 
		&discovery.AuthenticateParams{
        	DiscoveryMagicLinksToken: token,
	})
    if err != nil {
        log.Printf("Error authenticating: %v\n", err)
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
}

4
Create new Organization or login to existing Organization

At this point in the flow, the end user has authenticated but has not specified whether they want to create a new Organization or log into another Organization they belong to or can join through their verified email domain and JIT Provisioning.

s.client.MagicLinks.Discovery.Authenticate() will return an Intermediate Session Token (IST) which allows you to preserve the authentication state while you present the user with options on how they wish to proceed. For the purposes of this quickstart, we will automatically create a new Organization if the end user does not have any Organizations they can log into and otherwise log into their first Organization.

import (
	// Add new imports
	"github.com/stytchauth/stytch-go/v15/stytch/b2b/discovery/intermediatesessions"
	discoveryOrgs "github.com/stytchauth/stytch-go/v15/stytch/b2b/discovery/organizations"
)

func (s *AuthService) authenticateHandler(w http.ResponseWriter, r *http.Request) {
	tokenType := r.URL.Query().Get("stytch_token_type")
	token := r.URL.Query().Get("token")

    if tokenType != "discovery" {
        log.Printf("Error: unrecognized token type %s\n", tokenType)
	    http.Error(
			w,
			fmt.Sprintf("Unrecognized token type %s", tokenType),
			http.StatusBadRequest
		)
        return
    }

	resp, err := s.client.MagicLinks.Discovery.Authenticate(
		ctx,
		&discovery.AuthenticateParams{
        	DiscoveryMagicLinksToken: token,
    })
    if err != nil {
        log.Printf("Error authenticating: %v\n", err)
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    ist := resp.IntermediateSessionToken
   	var sessionToken string
	if len(resp.DiscoveredOrganizations) > 0 {
		organizationId := resp.DiscoveredOrganizations[0].Organization.OrganizationID
		resp, err := s.client.Discovery.IntermediateSessions.Exchange(
			ctx,
			&intermediatesessions.ExchangeParams{
				IntermediateSessionToken: ist,
				OrganizationID:           organizationId,
			})
		if err != nil {
			log.Printf("Error exchanging organization: %v\n", err)
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		sessionToken = resp.SessionToken
	} else {
		resp, err := s.client.Discovery.Organizations.Create(
			ctx,
			&discoveryOrgs.CreateParams{
				IntermediateSessionToken: ist,
			})
		if err != nil {
			log.Printf("Error creating organization: %v\n", err)
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		sessionToken = resp.SessionToken
	}

	// Store the session token
	session, _ := s.store.Get(r, sessionKey)
	session.Values["token"] = sessionToken
	_ = session.Save(r, w)

    w.WriteHeader(http.StatusOK)
    fmt.Fprintf(
        w, 
        "Welcome %s! You're logged into the %s organization",
        resp.Member.EmailAddress,
        resp.Organization.OrganizationName
    )
}

5
Add session protected route

Add a helper method that returns the session user information, and use it to gate any protected route that should only be accessed by an authenticated user.

import (
	// Add new import
	"github.com/stytchauth/stytch-go/v15/stytch/b2b/sessions"
	"github.com/stytchauth/stytch-go/v15/stytch/b2b/organizations"
)

func main() {
	// Add new HTTP handler
	mux.HandleFunc("/", service.indexHandler)
}

func (s *AuthService) indexHandler(w http.ResponseWriter, r *http.Request) {

	member, organization, ok := s.authenticatedMemberAndOrg(w, r)
	if !ok {
		w.WriteHeader(http.StatusOK)
		fmt.Fprintln(w, "Please log in to see this page")
		return
	}

	w.WriteHeader(http.StatusOK)
	fmt.Fprintf(
		w,
		"Welcome %s! You're logged into the %s organization",
		member.EmailAddress,
		organization.OrganizationName,
	)
}

func (s *AuthService) authenticatedMemberAndOrg(
	w http.ResponseWriter,
	r *http.Request,
) (*organizations.Member, *organizations.Organization, bool) {
	
	sessionToken, exists := s.getSession(w, r, sessionKey)
	if (!exists) {
		return nil, nil, false
	}

	resp, err := s.client.Sessions.Authenticate(
		ctx,
		&sessions.AuthenticateParams{
			SessionToken: sessionToken,
		})
	if err != nil {
		log.Printf("Error authenticating session: %v\n", err)
		s.clearSession(w, r, sessionKey)
		return nil, nil, false
	}

	return &resp.Member, &resp.Organization, true
}

func (s *AuthService) clearSession(w http.ResponseWriter, r *http.Request, key string) {
	session, _ := s.store.Get(r, key)
	delete(session.Values, "token")
	_ = s.store.Save(r, w, session)
}

func (s *AuthService) getSession(w http.ResponseWriter, r *http.Request, key string) (string, bool) {
	session, err := s.store.Get(r, key)
	if session == nil || err != nil {
		return "", false
	}
	token, ok := session.Values["token"].(string)
	return token, token != "" && ok
}

6
Test your application

Run your application

go run .

Send a POST request to the /login endpoint with your email address to initiate the Discovery flow, and then click on the email magic link you receive in your inbox to finish signing up or logging in.

What's next

Check out the example app here to see how you might extend this quickstart to enable JIT Provisioning by email domain, session exchange between orgs and more!

Completed example

package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"os"

	gorillaSessions "github.com/gorilla/sessions"
	"github.com/joho/godotenv"
	"github.com/stytchauth/stytch-go/v15/stytch/b2b/b2bstytchapi"
	"github.com/stytchauth/stytch-go/v15/stytch/b2b/discovery/intermediatesessions"
	discoveryOrgs "github.com/stytchauth/stytch-go/v15/stytch/b2b/discovery/organizations"
	"github.com/stytchauth/stytch-go/v15/stytch/b2b/magiclinks/discovery"
	emlDiscovery "github.com/stytchauth/stytch-go/v15/stytch/b2b/magiclinks/email/discovery"
	"github.com/stytchauth/stytch-go/v15/stytch/b2b/organizations"
	"github.com/stytchauth/stytch-go/v15/stytch/b2b/sessions"
)

var ctx = context.Background()

const sessionKey = "stytch_session_token"

func main() {
	// Load variables from .env file into the environment.
	if err := godotenv.Load(".env.local"); err != nil {
		log.Fatalf("Error loading .env file: %v", err)
	}

	// Instantiate a new API service.
	service := NewAuthService(
		os.Getenv("STYTCH_PROJECT_ID"),
		os.Getenv("STYTCH_SECRET"),
	)

	// Register HTTP handlers.
	mux := http.NewServeMux()
	mux.HandleFunc("/", service.indexHandler)
	mux.HandleFunc("/login", service.sendMagicLinkHandler)
	mux.HandleFunc("/authenticate", service.authenticateHandler)
	mux.HandleFunc("/logout", service.logoutHandler)

	// Start server.
	server := http.Server{
		Addr:    ":3000",
		Handler: mux,
	}
	log.Println("WARNING: For testing purposes only. Not intended for production use...")
	log.Println("Starting server on http://localhost:3000")
	if err := server.ListenAndServe(); err != nil {
		log.Fatal(err)
	}
}

type AuthService struct {
	client *b2bstytchapi.API
	store  *gorillaSessions.CookieStore
}

func NewAuthService(projectId, secret string) *AuthService {
	client, err := b2bstytchapi.NewClient(projectId, secret)
	if err != nil {
		log.Fatalf("Error creating client: %v", err)
	}

	return &AuthService{
		client: client,
		store:  gorillaSessions.NewCookieStore([]byte("your-secret-key")),
	}
}

func (s *AuthService) sendMagicLinkHandler(w http.ResponseWriter, r *http.Request) {
	if err := r.ParseForm(); err != nil {
		log.Printf("Error parsing form: %v\n", err)
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	email := r.Form.Get("email")
	if email == "" {
		http.Error(w, "Email is required", http.StatusBadRequest)
		return
	}

	_, err := s.client.MagicLinks.Email.Discovery.Send(
		ctx,
		&emlDiscovery.SendParams{
			EmailAddress: email,
		})
	if err != nil {
		log.Printf("Error sending email: %v\n", err)
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	w.WriteHeader(http.StatusOK)
	fmt.Fprintln(w, "Successfully sent magic link email!")
}

func (s *AuthService) authenticateHandler(w http.ResponseWriter, r *http.Request) {
	tokenType := r.URL.Query().Get("stytch_token_type")
	token := r.URL.Query().Get("token")

	if tokenType != "discovery" {
		log.Printf("Error: unrecognized token type %s\n", tokenType)
		http.Error(w, fmt.Sprintf("Unrecognized token type %s", tokenType), http.StatusBadRequest)
		return
	}

	resp, err := s.client.MagicLinks.Discovery.Authenticate(
		ctx,
		&discovery.AuthenticateParams{
			DiscoveryMagicLinksToken: token,
		})
	if err != nil {
		log.Printf("Error authenticating: %v\n", err)
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	ist := resp.IntermediateSessionToken
	var sessionToken string
	if len(resp.DiscoveredOrganizations) > 0 {
		organizationId := resp.DiscoveredOrganizations[0].Organization.OrganizationID
		resp, err := s.client.Discovery.IntermediateSessions.Exchange(
			ctx,
			&intermediatesessions.ExchangeParams{
				IntermediateSessionToken: ist,
				OrganizationID:           organizationId,
			})
		if err != nil {
			log.Printf("Error exchanging organization: %v\n", err)
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		sessionToken = resp.SessionToken
	} else {
		resp, err := s.client.Discovery.Organizations.Create(
			ctx,
			&discoveryOrgs.CreateParams{
				IntermediateSessionToken: ist,
			})
		if err != nil {
			log.Printf("Error creating organization: %v\n", err)
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		sessionToken = resp.SessionToken
	}

	// Store the session token
	session, _ := s.store.Get(r, sessionKey)
	session.Values["token"] = sessionToken
	_ = session.Save(r, w)

	s.indexHandler(w, r)
}

func (s *AuthService) indexHandler(w http.ResponseWriter, r *http.Request) {

	member, organization, ok := s.authenticatedMemberAndOrg(w, r)
	if !ok {
		w.WriteHeader(http.StatusOK)
		fmt.Fprintln(w, "Please log in to see this page")
		return
	}

	w.WriteHeader(http.StatusOK)
	fmt.Fprintf(
		w,
		"Welcome %s! You're logged into the %s organization",
		member.EmailAddress,
		organization.OrganizationName,
	)
}

func (s *AuthService) logoutHandler(w http.ResponseWriter, r *http.Request) {
	s.clearSession(w, r, sessionKey)
	s.indexHandler(w, r)
	return
}

func (s *AuthService) authenticatedMemberAndOrg(
	w http.ResponseWriter,
	r *http.Request,
) (*organizations.Member, *organizations.Organization, bool) {

	sessionToken, exists := s.getSession(w, r, sessionKey)
	if !exists {
		return nil, nil, false
	}

	resp, err := s.client.Sessions.Authenticate(
		ctx,
		&sessions.AuthenticateParams{
			SessionToken: sessionToken,
		})
	if err != nil {
		log.Printf("Error authenticating session: %v\n", err)
		s.clearSession(w, r, sessionKey)
		return nil, nil, false
	}

	return &resp.Member, &resp.Organization, true
}

func (s *AuthService) clearSession(w http.ResponseWriter, r *http.Request, key string) {
	session, _ := s.store.Get(r, key)
	delete(session.Values, "token")
	_ = s.store.Save(r, w, session)
}

func (s *AuthService) getSession(w http.ResponseWriter, r *http.Request, key string) (string, bool) {
	session, err := s.store.Get(r, key)
	if session == nil || err != nil {
		return "", false
	}
	token, ok := session.Values["token"].(string)
	return token, ok && token != ""
}