How to Authenticate with Magic Links in Go

December 02, 2025
Written by
Tolulope Babatunde
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

How to Build a magic link using Go and Twilio

Magic links are secure, single-use URLs sent to users, typically via email or SMS, to authenticate them, granting them access to an application — without requiring a traditional password. They are widely used for passwordless authentication, where users can log in simply by clicking the link.

In this tutorial, we’ll build such an application using Go to handle the backend logic and Twilio's Programmable Messaging API for sending links via SMS.

Prerequisites

Before diving into the code, ensure you have the following:

Create the application

Okay, let's build the application. Start by creating a new directory named "magic_link" wherever you create your Go projects, changing into the directory, and initialising a new Go module with the following commands:

mkdir magic_link
cd magic_link
go mod init magic_link

Set the required environment variables

To keep sensitive credentials like API keys out of your source code, we'll load them from environment variables. This makes the app more secure and easier to configure across different environments.

To do this, create a file named .env in your project's top-level folder and copy the following credentials into it:

TWILIO_ACCOUNT_SID="<your twilio sid>"
TWILIO_AUTH_TOKEN="<your twilio auth token>"
TWILIO_PHONE_NUMBER="<your twilio phone number>"
PORT=8080
NGROK_URL="<your ngrok url>"

Next, log in to the Twilio Console, and from the main dashboard, copy your Account SID, Auth Token, and phone number. After that, paste them into .env in place of <your twilio sid>, <your twilio auth token>, and <your twilio phone number> respectively..

The image shows available phone numbers for purchase on the dashboard.
The image shows available phone numbers for purchase on the dashboard.

You also need an ngrok Forwarding URL. pen up a terminal and have ngrok expose port 8080 on your local machine to the internet using this command:

ngrok http 8080

Then, copy the Forwarding URL printed to the terminal and paste it in place of <your ngrok url> in .env.

Set up the project

You next need to install the following dependencies:

  • github.com/twilio/twilio-go: This is the official Twilio Go Helper Library, which simplifies interacting with Twilio services like SMS, voice, and other communication tools. For this project, it will be used to send magic links via SMS.
  • github.com/joho/godotenv: This package will help us manage our environment variables.
  • github.com/mattn/go-sqlite3: This package is a Go driver for SQLite. In this project, SQLite will store user data and magic links, ensuring authentication tokens are managed securely and expire after a set period.

To install all of these, run the following command in a new terminal tab or session:

go get github.com/twilio/twilio-go github.com/joho/godotenv github.com/mattn/go-sqlite3

Set up the database and define the database models

In this tutorial, we’ll use SQLite to store user information and issue magic links. This helps us track users, generate secure tokens, check if links have expired, and make sure each link is only used once. Alongside that, we’ll define Go structs (models) that match the database schema — these will make it easier to work with users and magic links in code.

Create a subdirectory called database, in the project's top-level directory, and inside it create a file called sqlite.go. In the new file, add the following code:

package database

import (
  "database/sql"
  _ "github.com/mattn/go-sqlite3"
)

func NewSQLiteDB(dbPath string) (*sql.DB, error) {
  db, err := sql.Open("sqlite3", dbPath)
  if err != nil {
    return nil, err
  }

  _, err = db.Exec(`
    CREATE TABLE IF NOT EXISTS users (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      username TEXT UNIQUE NOT NULL,
      password TEXT NOT NULL,
      phone_number TEXT UNIQUE NOT NULL
    )
  `)
  if err != nil {
    return nil, err
  }

  _, err = db.Exec(`
    CREATE TABLE IF NOT EXISTS magic_links (
      token TEXT PRIMARY KEY,
      user_id INTEGER NOT NULL,
      expires_at DATETIME NOT NULL,
      used INTEGER DEFAULT 0,
      FOREIGN KEY(user_id) REFERENCES users(id)
    )
  `)
  if err != nil {
    return nil, err
  }

  _, err = db.Exec(`
    INSERT OR IGNORE INTO users (username, password, phone_number)
    VALUES
    ('John Doe', 'password123', '<your phone number>'),
    ('testuser2', 'password456', '<your phone number>')
  `)
  if err != nil {
    return nil, err
  }

  return db, nil
}

Now, in sqlite.go replace <your phone number> with the mobile/cell phone number that will receive SMS from the application.

In the code, we create two tables. The first is the "users" table, which stores the user details. The second is the "magic_links" table, which stores the link with expiration and usage status.

Next, create another directory in the project's top-level directory called models, and inside it, create a file called users.go. Then, add the following code to the new file:

package models

type User struct {
	ID          int64
	Username    string
	Password    string
	PhoneNumber string
}

Thecode above contains the data structure for the user, storing the essential information about them.

You now need to create another file inside the models directory called models.go. Inside it, add the following code:

package models

import "time"

type MagicLink struct {
	Token     string
	UserID    int64
	ExpiresAt time.Time
	Used      bool
}

The code above is a struct that represents a magic link in the database. It includes a secure token, a reference to the user it belongs to, an expiration time, and a flag to check if it's already been used. This is all the essential details needed for safe, one-time authentication.

Set up the application's configuration and services

Now that the database and models are in place, you need to set up the application's configuration, and services for loading and the core logic for sending and verifying magic links. This includes loading environment variables, generating secure tokens, and sending SMS messages through Twilio.

Create a directory called config in the project's top-level directory and inside it create a file called config.go. Then, add the following code to the new file:

package config

import (
  "fmt"
  "os"
  "github.com/joho/godotenv"
)

type Config struct {
  TwilioAccountSID  string
  TwilioAuthToken   string
  TwilioPhoneNumber string
  BaseURL           string
  Port              string
}

func Load() (*Config, error) {
  if err := godotenv.Load(); err != nil {
    return nil, err
  }
  port := os.Getenv("PORT")
  if port == "" {
    port = "8080"
  }

  baseURL := os.Getenv("NGROK_URL")
  if baseURL == "" {
    baseURL = fmt.Sprintf("http://localhost:%s", port)
  }

  return &Config{
    TwilioAccountSID:  os.Getenv("TWILIO_ACCOUNT_SID"),
    TwilioAuthToken:   os.Getenv("TWILIO_AUTH_TOKEN"),
    TwilioPhoneNumber: os.Getenv("TWILIO_PHONE_NUMBER"),
    BaseURL:           baseURL,
    Port:              port,
  }, nil
}

This code handles loading app configuration from .env using the GoDotEnv package. It pulls in values like your Twilio credentials, the app port, and the ngrok URL, then stores them in a Config struct.

If PORT or NGROK_URL aren’t set, it falls back to sensible defaults; 8080 for the port and a localhost URL for the base URL. The Load() function returns a fully populated Config instance, which the rest of the app can use to stay flexible and keep sensitive data out of the codebase.

Next, we’ll implement the application’s core services — these are reusable components that handle the main logic of our app, like generating magic links, verifying them, and sending SMS messages. Let’s start with the service responsible for creating and managing magic links.

For the services, create a subdirectory called services in the project's top-level directory, and inside it create a file called magic_links.go. Now, add the following code to the new file:

package services

import (
  "crypto/rand"
  "database/sql"
  "encoding/base64"
  "fmt"
  "time"
  "magic_link/models"
)

type MagicLinkService struct {
  db      *sql.DB
  baseURL string
}

func NewMagicLinkService(db *sql.DB, baseURL string) *MagicLinkService {
  return &MagicLinkService{
    db:      db,
    baseURL: baseURL,
  }
}

func (s *MagicLinkService) generateSecureToken() (string, error) {
  bytes := make([]byte, 32)
  if _, err := rand.Read(bytes); err != nil {
    return "", err
  }
  return base64.URLEncoding.EncodeToString(bytes), nil
}

func (s *MagicLinkService) GetUserByPhoneNumber(phoneNumber string) (*models.User, error) {
  var user models.User
  err := s.db.QueryRow(
    "SELECT id, username, phone_number FROM users WHERE phone_number = ?",
    phoneNumber,
  ).Scan(&user.ID, &user.Username, &user.PhoneNumber)
  if err == sql.ErrNoRows {
    return nil, fmt.Errorf("no user found with this phone number")
  }
  if err != nil {
    return nil, err
  }
  return &user, nil
}

func (s *MagicLinkService) CreateMagicLink(phoneNumber string) (*models.MagicLink, *models.User, error) {
  user, err := s.GetUserByPhoneNumber(phoneNumber)
  if err != nil {
    return nil, nil, err
  }
  token, err := s.generateSecureToken()
  if err != nil {
    return nil, nil, err
  }

  magicLink := &models.MagicLink{
    Token:     token,
    UserID:    user.ID,
    ExpiresAt: time.Now().Add(15 * time.Minute),
    Used:      false,
  }
  _, err = s.db.Exec(
    "INSERT INTO magic_links (token, user_id, expires_at, used) VALUES (?, ?, ?, ?)",
    magicLink.Token,
    magicLink.UserID,
    magicLink.ExpiresAt,
    0,
  )
  if err != nil {
    return nil, nil, err
  }
  return magicLink, user, nil
}

func (s *MagicLinkService) VerifyMagicLink(token string) (*models.User, error) {
  var link models.MagicLink
  var user models.User
  var used int
  err := s.db.QueryRow(`
    SELECT m.token, m.user_id, m.expires_at, m.used,
           u.id, u.username, u.phone_number
    FROM magic_links m
    JOIN users u ON m.user_id = u.id
    WHERE m.token = ?`,
    token,
  ).Scan(
    &link.Token,
    &link.UserID,
    &link.ExpiresAt,
    &used,
    &user.ID,
    &user.Username,
    &user.PhoneNumber,
  )
  if err == sql.ErrNoRows {
    return nil, fmt.Errorf("invalid token")
  }
  if err != nil {
    return nil, err
  }
  link.Used = used == 1
  if time.Now().After(link.ExpiresAt) {
    return nil, fmt.Errorf("token expired")
  }
  if link.Used {
    return nil, fmt.Errorf("token already used")
  }
  _, err = s.db.Exec("UPDATE magic_links SET used = 1 WHERE token = ?", token)
  if err != nil {
    return nil, err
  }
  return &user, nil
}

func (s *MagicLinkService) GetMagicLinkURL(token string) string {
  return fmt.Sprintf("%s/verify?token=%s", s.baseURL, token)
}

The code above generates a secure, 32-byte token using the crypto/rand package. It also retrieves users by phone number, and creates and stores magic links in the database.

There is also a verification method that checks if the provided token exists, hasn’t expired, and hasn’t already been used. If the token is valid, the associated user is returned and the token is marked as used to prevent reuse.

It also handles magic link verification by checking if the token is valid, hasn’t expired, and hasn’t already been used. If all checks pass, the user is successfully authenticated; otherwise, an appropriate error is returned.

Next, still inside the services directory, create another file called sms.go. Then, add the following code to the new file:

package services

import (
  "github.com/twilio/twilio-go"
  twilioApi "github.com/twilio/twilio-go/rest/api/v2010"
)

type SMSService interface {
  SendSMS(to, message string) error
}

type TwilioService struct {
  client      *twilio.RestClient
  phoneNumber string
}

func NewTwilioService(accountSID, authToken, phoneNumber string) SMSService {
  client := twilio.NewRestClientWithParams(twilio.ClientParams{
    Username: accountSID,
    Password: authToken,
  })
  return &TwilioService{
    client:      client,
    phoneNumber: phoneNumber,
  }
}

func (s *TwilioService) SendSMS(to, message string) error {
  params := &twilioApi.CreateMessageParams{
    To:   &to,
    From: &s.phoneNumber,
    Body: &message,
  }
  _, err := s.client.Api.CreateMessage(params)
  return err
}

This code defines a service for sending SMS messages using Twilio. It implements the SMSService interface with a single method, SendSMS(). The TwilioService struct holds a Twilio client and the sender's phone number. The NewTwilioService() function initializes the client using your Account SID and Auth Token. When SendSMS() is called, it creates and sends a message through Twilio’s Programmable Messaging API using the provided recipient number and message body.

Implement route handlers and the main function

Now that the services are ready, it’s time to hook them up to actual HTTP routes. In this section, we’ll build the request handlers that process form submissions, trigger SMS messages, and handle verification. We’ll also set up the main.go file to wire everything together and start the web server.

Create a directory called handlers in the root of your project. Inside the handlers directory, create a file called magic_link.go (this is separate from the one in the services directory). Then, add the following code to the new file:

package handlers

import (
  "encoding/json"
  "fmt"
  "html/template"
  "net/http"
  "path/filepath"
  "magic_link/services"
)

type MagicLinkHandler struct {
  magicLinkService *services.MagicLinkService
  smsService       services.SMSService
  templates        *template.Template
}

func NewMagicLinkHandler(
  magicLinkService *services.MagicLinkService,
  smsService services.SMSService,
) *MagicLinkHandler {
  templates := template.Must(template.ParseFiles(
    filepath.Join("templates", "login.html"),
    filepath.Join("templates", "verify.html"),
  ))
  return &MagicLinkHandler{
    magicLinkService: magicLinkService,
    smsService:       smsService,
    templates:        templates,
  }
}

func (h *MagicLinkHandler) ServeLoginPage(w http.ResponseWriter, r *http.Request) {
  if r.Method != http.MethodGet {
    http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
    return
  }
  h.templates.ExecuteTemplate(w, "login.html", nil)
}

func (h *MagicLinkHandler) ServeVerifyPage(w http.ResponseWriter, r *http.Request) {
  if r.Method != http.MethodGet {
    http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
    return
  }
  h.templates.ExecuteTemplate(w, "verify.html", nil)
}

func (h *MagicLinkHandler) RequestMagicLink(w http.ResponseWriter, r *http.Request) {
  if r.Method != http.MethodPost {
    http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
    return
  }
  phoneNumber := r.FormValue("phone_number")
  if phoneNumber == "" {
    http.Error(w, "Phone number is required", http.StatusBadRequest)
    return
  }

  magicLink, user, err := h.magicLinkService.CreateMagicLink(phoneNumber)
  if err != nil {
    http.Error(w, "Invalid phone number or user not found", http.StatusBadRequest)
    return
  }

  message := fmt.Sprintf(
    "Hi %s! Here's your login link: %s (Valid for 15 minutes)",
    user.Username,
    h.magicLinkService.GetMagicLinkURL(magicLink.Token),
  )
  err = h.smsService.SendSMS(user.PhoneNumber, message)
  if err != nil {
    http.Error(w, "Error sending SMS", http.StatusInternalServerError)
    return
  }

  w.WriteHeader(http.StatusOK)
  json.NewEncoder(w).Encode(map[string]string{
    "message": "Magic link sent successfully",
  })
}

func (h *MagicLinkHandler) VerifyMagicLink(w http.ResponseWriter, r *http.Request) {
  if r.Method != http.MethodGet {
    http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
    return
  }

  token := r.URL.Query().Get("token")
  if token == "" {
    http.Error(w, "Token is required", http.StatusBadRequest)
    return
  }

  user, err := h.magicLinkService.VerifyMagicLink(token)
  if err != nil {
    http.Error(w, err.Error(), http.StatusBadRequest)
    return
  }

  w.WriteHeader(http.StatusOK)
  json.NewEncoder(w).Encode(map[string]string{
    "message":  fmt.Sprintf("Welcome back, %s!", user.Username),
    "username": user.Username,
  })
}

The code above defines the HTTP handlers responsible for the main user interactions. ServeLoginPage() and ServeVerifyPage() render the login and verification HTML templates. RequestMagicLink() handles form submissions from the login page. It accepts a phone number, generates a magic link, and sends it via SMS. VerifyMagicLink() checks if the token in the URL is valid, expired, or already used, and returns a success or error response accordingly.

We’ve built the handlers and connected the core logic. Now, let’s bring everything together in main.go. This file sets up configuration, connects to the database, initializes services and handlers, and starts the HTTP server.

Create a main.go file inside of the root directory. Then, add the following code to the file:

package main

import (
   "log"
   "net/http"
   "magic_link/config"
   "magic_link/database"
   "magic_link/handlers"
   "magic_link/services"
)

func main() {
   cfg, err := config.Load()
   if err != nil {
       log.Fatal(err)
   }
   db, err := database.NewSQLiteDB("magic_links.db")
   if err != nil {
       log.Fatal(err)
   }
   defer db.Close()
   smsService := services.NewTwilioService(cfg.TwilioAccountSID, cfg.TwilioAuthToken, cfg.TwilioPhoneNumber)
   magicLinkService := services.NewMagicLinkService(db, cfg.BaseURL)

   handler := handlers.NewMagicLinkHandler(magicLinkService, smsService)
   http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
   http.HandleFunc("/", handler.ServeLoginPage)
   http.HandleFunc("/verify", handler.ServeVerifyPage)
   http.HandleFunc("/request-magic-link", handler.RequestMagicLink)
   http.HandleFunc("/api/verify", handler.VerifyMagicLink)
   log.Printf("Server starting on port %s", cfg.Port)
   log.Fatal(http.ListenAndServe(":"+cfg.Port, nil))
}

Create the required templates

For the template layer, you’ll need three files, two HTML files and a JavaScript file. Create a templates subdirectory in the project's top-level directory. This will contain all three files you’ll be creating.

The first HTML file is called login.html and contains the following code:

<!DOCTYPE html>
<html lang="en">
 <head>
   <meta charset="UTF-8" />
   <meta name="viewport" content="width=device-width, initial-scale=1.0" />
   <title>Login</title>
   <script src="https://cdn.tailwindcss.com"></script>
 </head>
 <body class="bg-gray-100 min-h-screen flex items-center justify-center">
   <div class="bg-white p-8 rounded-lg shadow-md w-96">
     <h1 class="text-2xl font-bold mb-6 text-center text-gray-800">
       Login with Magic Link
     </h1>
     <form id="loginForm" class="space-y-4">
       <div>
         <label for="phone" class="block text-sm font-medium text-gray-700"
           >Phone Number</label
         >
         <input
           type="tel"
           id="phone"
           name="phone_number"
           required
           class="mt-1 block w-full rounded-md border-gray-300 shadow-sm p-2 border"
           placeholder="+1234567890"
         />
       </div>
       <button
         type="submit"
         class="w-full bg-blue-500 text-white py-2 px-4 rounded-md hover:bg-blue-600 transition duration-200"
       >
         Send Magic Link
       </button>
     </form>
     <div id="message" class="mt-4 text-center hidden">
       <p class="text-green-600">A link was sent! Please check your phone.</p>
     </div>
     <div id="error" class="mt-4 text-center hidden">
       <p class="text-red-600" id="errorText">
         Error sending link. Please try again.
       </p>
     </div>
   </div>
   <script>
     document
       .getElementById("loginForm")
       .addEventListener("submit", async (e) => {
         e.preventDefault();
         const messageDiv = document.getElementById("message");
         const errorDiv = document.getElementById("error");
         const errorText = document.getElementById("errorText");
         messageDiv.classList.add("hidden");
         errorDiv.classList.add("hidden");
         const formData = new FormData(e.target);
         try {
           const response = await fetch("/request-magic-link", {
             method: "POST",
             body: formData,
           });
           const data = await response.text();
           if (response.ok) {
             messageDiv.classList.remove("hidden");
             e.target.reset();
           } else {
             errorText.textContent = data;
             errorDiv.classList.remove("hidden");
           }
         } catch (error) {
           errorText.textContent = "An error occurred. Please try again.";
           errorDiv.classList.remove("hidden");
         }
       });
   </script>
 </body>
</html>

The second HTML file is called verify.html and will contain the following code:

<!DOCTYPE html>
<html lang="en">
 <head>
   <meta charset="UTF-8" />
   <meta name="viewport" content="width=device-width, initial-scale=1.0" />
   <title>Verify Magic Link</title>
   <script src="https://cdn.tailwindcss.com"></script>
 </head>
 <body class="bg-gray-100 min-h-screen flex items-center justify-center">
   <div class="bg-white p-8 rounded-lg shadow-md w-96">
     <div id="loading" class="text-center">
       <div
         class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"
       ></div>
       <p class="mt-4 text-gray-600">Verifying your magic link...</p>
     </div>
     <div id="success" class="text-center hidden">
       <svg
         class="h-12 w-12 text-green-500 mx-auto"
         fill="none"
         stroke="currentColor"
         viewBox="0 0 24 24"
       >
         <path
           stroke-linecap="round"
           stroke-linejoin="round"
           stroke-width="2"
           d="M5 13l4 4L19 7"
         ></path>
       </svg>
       <h2 class="mt-4 text-xl font-bold text-gray-800">
         Successfully Authenticated!
       </h2>
       <p id="userId" class="mt-2 text-gray-600"></p>
       <a href="/" class="mt-4 inline-block text-blue-500 hover:text-blue-600"
         >Back to Login</a
       >
     </div>
     <div id="error" class="text-center hidden">
       <svg
         class="h-12 w-12 text-red-500 mx-auto"
         fill="none"
         stroke="currentColor"
         viewBox="0 0 24 24"
       >
         <path
           stroke-linecap="round"
           stroke-linejoin="round"
           stroke-width="2"
           d="M6 18L18 6M6 6l12 12"
         ></path>
       </svg>
       <h2 class="mt-4 text-xl font-bold text-gray-800">
         Verification Failed
       </h2>
       <p id="errorMessage" class="mt-2 text-gray-600">
         Invalid or expired magic link.
       </p>
       <a href="/" class="mt-4 inline-block text-blue-500 hover:text-blue-600"
         >Try Again</a
       >
     </div>
   </div>
   <script>
     document.addEventListener("DOMContentLoaded", async () => {
       const urlParams = new URLSearchParams(window.location.search);
       const token = urlParams.get("token");
       if (!token) {
         showError("No token provided");
         return;
       }
       try {
         const response = await fetch(`/api/verify?token=${token}`);
         const data = await response.json(); 
         if (response.ok) {
           showSuccess(data.message); 
         } else {
           showError(data.message || "Verification failed");
         }
       } catch (error) {
         showError("An error occurred during verification");
       }
     });
     function showSuccess(message) {
       document.getElementById("loading").classList.add("hidden");
       document.getElementById("success").classList.remove("hidden");
       document.getElementById("userId").textContent = message;
     }
     function showError(message) {
       document.getElementById("loading").classList.add("hidden");
       document.getElementById("error").classList.remove("hidden");
       document.getElementById("errorMessage").textContent = message;
     }
   </script>
 </body>
</html>

Lastly, the JavaScript file will be called script.js and it will have the following code:

async function requestMagicLink() {
 const email = document.getElementById("email").value;
 const phone = document.getElementById("phone").value;
 if (!email || !phone) {
   alert("Please enter both email and phone number.");
   return;
 }

 try {
   const response = await fetch("http://localhost:8080/auth/request", {
     method: "POST",
     headers: { "Content-Type": "application/json" },
     body: JSON.stringify({ email, phone }),
   });
   if (!response.ok) {
     throw new Error(`Server error: ${response.status}`);
   }

   const result = await response.json();
   alert(result.message);
 } catch (error) {
   console.error("Error:", error);
   alert("Failed to connect to the server. Is the backend running?");
 }
}

Test the application

Since ngrok is already running, you only need to start up the application server by running the following command:

go run main.go

Navigate to your ngrok Forwarding URL in your local browser where you should see it look like the screenshot below:

Fill out the form by inputting your phone number and submit the form. You should receive an SMS containing the magic link soon after submitting the form, which looks similar to the screenshot below.

Image displaying the magic link sent to the phone number entered on the form.
Image displaying the magic link sent to the phone number entered on the form.

Click on it and you’ll see it verifies your login.

That’s how to build a magic link in go

In this article, we took a look at how to build a magic link in Go using Twilio, withSQLite as the database. You can improve on this project by adding email service to also send you a link, and SendGrid can be used for this purpose.

Tolulope Babatunde is a software developer with a passion for translating complex concepts in clear content through tech writing.

Magic icons and Link icons created by Freepik on Flaticon.