API key authentication for a Go service with an Encore auth handler or net/http middleware.
Authentication in a Go API starts as a few lines of middleware that check a header, and grows as more endpoints arrive. Some routes are public, some need a key, the user needs to reach the handlers, and other services calling the API need to forward who the caller is. Getting one of those wrong usually means an endpoint that is open when it should not be.
This guide adds API key authentication to a Go API. Keys are random strings handed to customers, stored as SHA-256 hashes in Postgres, and each key maps to a user and a plan. It is shown with an Encore auth handler and with net/http middleware.
Encore combines an open source infrastructure SDK for Go and TypeScript with a platform that deploys into your own AWS or GCP account. An auth handler is a function that receives the bearer token and returns a user ID and a struct of user data. Encore calls it before any endpoint marked auth:
// Service auth validates API keys for every endpoint marked "auth".
package auth
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"encore.dev/beta/auth"
"encore.dev/beta/errs"
"encore.dev/storage/sqldb"
)
var db = sqldb.NewDatabase("auth", sqldb.DatabaseConfig{
Migrations: "./migrations",
})
// UserData is available to handlers via auth.Data().
type UserData struct {
Email string `json:"email"`
Plan string `json:"plan"`
}
// AuthHandler runs before every endpoint that requires auth.
// Encore reads the token from the Authorization: Bearer header.
//
//encore:authhandler
func AuthHandler(ctx context.Context, token string) (auth.UID, *UserData, error) {
sum := sha256.Sum256([]byte(token))
var (
uid string
data UserData
)
err := db.QueryRow(ctx, `
SELECT user_id, email, plan FROM api_keys
WHERE key_hash = $1 AND revoked_at IS NULL`,
hex.EncodeToString(sum[:]),
).Scan(&uid, &data.Email, &data.Plan)
if errors.Is(err, sqldb.ErrNoRows) {
return "", nil, &errs.Error{Code: errs.Unauthenticated, Message: "invalid api key"}
} else if err != nil {
return "", nil, err
}
return auth.UID(uid), &data, nil
}
The migration creates the table and seeds a local key. Only the hash is stored:
CREATE TABLE api_keys (
key_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
email TEXT NOT NULL,
plan TEXT NOT NULL DEFAULT 'free',
revoked_at TIMESTAMPTZ
);
-- Seed a local key: the SHA-256 of "dev-key-123".
INSERT INTO api_keys (key_hash, user_id, email, plan) VALUES
(encode(sha256('dev-key-123'), 'hex'), 'user_1', 'ada@example.com', 'pro');
Any service in the app can then declare an endpoint that requires a signed-in caller and read the user from the request:
// Service account serves endpoints for signed-in users.
package account
import (
"context"
"encore.app/auth"
encauth "encore.dev/beta/auth"
)
type Me struct {
UserID string `json:"userId"`
Email string `json:"email"`
Plan string `json:"plan"`
}
// GetMe requires a valid API key; Encore rejects the call before it runs otherwise.
//
//encore:api auth method=GET path=/me
func GetMe(ctx context.Context) (*Me, error) {
uid, _ := encauth.UserID()
data := encauth.Data().(*auth.UserData)
return &Me{UserID: string(uid), Email: data.Email, Plan: data.Plan}, nil
}
encore run starts Postgres locally and applies the migration, so the seeded key works straight away:
When one service calls another, Encore forwards the authenticated user, so a billing endpoint called from account sees the same auth.UserID() without passing headers along. The generated API clients send the token for endpoints that need it. The database becomes RDS or Cloud SQL in your own account when you deploy.
Every protected endpoint carries the auth tag in its annotation, so an AI agent adding a route can see which access level its neighbours use, and an unauthenticated curl under encore run confirms it before review.
Run in your terminal to get started locally.
With the standard library, the same check is middleware that puts the user on the request context. An unexported key type keeps other packages from colliding with it:
type ctxKey struct{}
type User struct {
ID, Email, Plan string
}
func requireKey(db *pgxpool.Pool, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
if !ok || token == "" {
http.Error(w, "missing api key", http.StatusUnauthorized)
return
}
sum := sha256.Sum256([]byte(token))
var u User
err := db.QueryRow(r.Context(), `
SELECT user_id, email, plan FROM api_keys
WHERE key_hash = $1 AND revoked_at IS NULL`,
hex.EncodeToString(sum[:]),
).Scan(&u.ID, &u.Email, &u.Plan)
if err != nil {
http.Error(w, "invalid api key", http.StatusUnauthorized)
return
}
next(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, u)))
}
}
func userFrom(ctx context.Context) User {
u, _ := ctx.Value(ctxKey{}).(User)
return u
}
// mux.HandleFunc("GET /me", requireKey(pool, getMe))
With this approach, every protected route has to be wrapped by hand, a database error is reported as an invalid key here unless you separate the two cases, and a call from this service to another one has to forward the key or an internal token by hand. Libraries such as go-chi/jwtauth cover the JWT case for chi routers, and the OWASP API Security Top 10 lists broken authentication as one of the most common API risks.
Pick Encore when you want one auth handler for every service in the app, endpoints that declare their access level in the annotation, user data forwarded between services, and the app deployed to your own AWS or GCP account. If you later move to JWTs from an identity provider, the handler receives the raw token and can verify it in the same place.
Pick net/http middleware for a single small service where the route list is short and no other service needs the caller's identity.
With Encore, write one function marked //encore:authhandler that takes the bearer token and returns an auth.UID and user data, then mark endpoints with //encore:api auth. Encore runs the handler before those endpoints and rejects the request with 401 when it returns an error.
No. Store a hash of each key, such as SHA-256 for long random keys, and compare hashes on each request. A leaked database then does not leak working keys, and you can revoke a key by marking its row.
Have the middleware put the user on the request context with context.WithValue using an unexported key type, and read it in the handler with a typed helper function.
Deploy to your cloud on AWS and Google Cloud
without managing infrastructure
Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.