Getting started: authenticate an HTTP request end to end¶
By the end of this you'll have a running HTTP server that rejects unauthenticated
callers with a 401, accepts a configured API key, and reads the verified identity
inside the handler. Everything runs locally and takes about fifteen minutes.
authn has no HTTP or gRPC imports, so the middleware you write here is yours — the
package supplies the verification and the identity, and nothing else. That is the
point of it, and it means the same three lines work unchanged from a gRPC interceptor.
Before you start¶
You'll need Go 1.26 or newer and a terminal. No network services, no test issuer, no certificates: the whole tutorial uses an API key, which is the one verifier that needs nothing external. JWT and mTLS come later, in Choose & configure a verifier.
Create the module¶
mkdir authn-demo && cd authn-demo
go mod init example.com/authn-demo
go get gitlab.com/phpboyscout/go/authn
The go get pulls in cockroachdb/errors and golang-jwt/jwt/v5 and nothing else.
Build a verifier and check a credential¶
Start with the smallest thing that works. Put this in main.go:
package main
import (
"context"
"fmt"
"gitlab.com/phpboyscout/go/authn"
)
func main() {
verifier, err := authn.NewAPIKeyVerifier(
authn.KeyEntry{Key: "s3cr3t", Subject: "ci-bot"},
)
if err != nil {
panic(err)
}
id, err := verifier.Verify(context.Background(), "s3cr3t")
if err != nil {
panic(err)
}
fmt.Printf("subject=%q method=%q\n", id.Subject, id.Method)
}
Run it:
Subject is the label you attached to that key, and Method records which verifier
authenticated the caller — "apikey" here, "jwt" or "mtls" for the others. Those
two fields are what your handlers act on.
One thing worth knowing now rather than discovering later: NewAPIKeyVerifier refuses
to build a verifier with no keys at all. An empty key set would mean "accept anything",
so it returns authn: API-key verifier requires at least one key (fail-closed) instead.
See a failure, and see what it tells you¶
Change the credential passed to Verify to something wrong:
That is the whole message, and it is deliberate. A Verifier never encodes why
authentication failed in anything you could safely hand back to a caller — no "unknown
key", no "expired token". Telling an attacker which part of their guess was wrong is an
oracle, so every failure looks the same from outside.
Put the credential back to "s3cr3t" before continuing.
Wrap the verifier in middleware¶
Now the real shape. Replace main.go with this:
package main
import (
"context"
"fmt"
"net/http"
"strings"
"gitlab.com/phpboyscout/go/authn"
)
func authenticate(v authn.Verifier, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
credential := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
id, err := v.Verify(r.Context(), credential)
if err != nil {
// Log err server-side (redacted). Never send it to the client.
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r.WithContext(authn.ContextWithIdentity(r.Context(), id)))
})
}
func whoami(w http.ResponseWriter, r *http.Request) {
id, ok := authn.IdentityFromContext(r.Context())
if !ok {
http.Error(w, "no identity", http.StatusInternalServerError)
return
}
fmt.Fprintf(w, "hello %s (via %s)\n", id.Subject, id.Method)
}
func main() {
verifier, err := authn.NewAPIKeyVerifier(
authn.KeyEntry{Key: "s3cr3t", Subject: "ci-bot"},
)
if err != nil {
panic(err)
}
http.Handle("/whoami", authenticate(verifier, http.HandlerFunc(whoami)))
_ = http.ListenAndServe("127.0.0.1:8080", nil)
}
Three things are happening, and only the middle one is authn:
- Extract the credential. Pulling the bearer token out of the
Authorizationheader is transport glue you own —authnnever looks at a request. - Verify it. One call, one error, one
Identity. - Carry the identity forward.
ContextWithIdentityputs it on the request context;IdentityFromContextreads it back in the handler. The HTTP and gRPC adapters use the same context key, so a handler reads identity the same way either way.
Start it:
Try it from another terminal¶
Without a credential:
With the wrong one:
Identical. No hint about which part was wrong.
With the right one:
Add an authorization check¶
Authentication got you a name. Deciding what that name may do is a separate step, and
authn keeps it separate on purpose: it is a plain predicate you supply, and it maps
to 403, not 401.
Add a second key with a different label, and a predicate that lets only one of them
through. In main:
verifier, err := authn.NewAPIKeyVerifier(
authn.KeyEntry{Key: "s3cr3t", Subject: "ci-bot"},
authn.KeyEntry{Key: "adm1n", Subject: "admin"},
)
if err != nil {
panic(err)
}
var authorize authn.AuthorizeFunc = func(_ context.Context, id *authn.Identity) bool {
return id != nil && id.Subject == "admin"
}
http.Handle("/whoami", authenticate(verifier, http.HandlerFunc(whoami)))
http.Handle("/admin", authenticate(verifier,
authorized(authorize, http.HandlerFunc(whoami))))
And add the second middleware:
func authorized(allow authn.AuthorizeFunc, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id, _ := authn.IdentityFromContext(r.Context())
if !allow(r.Context(), id) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
Restart, then compare the two keys against /admin:
curl -i -H 'Authorization: Bearer s3cr3t' http://127.0.0.1:8080/admin
curl -i -H 'Authorization: Bearer adm1n' http://127.0.0.1:8080/admin
The ci-bot key is a perfectly valid credential — it authenticated fine. It just isn't
allowed here, and 403 says exactly that. Getting 401 and 403 the right way round
is most of what the authn/authz split buys you.
Guard against a nil identity in any predicate you write, as the example does. The
built-in RequireScopes
and RequireClaim
combinators already do, and both return false for nil rather than panicking.
What to read next¶
- Choose & configure a verifier — swap the API key for JWT/OIDC or mTLS.
- Authorize verified requests — scopes, claims and route-aware predicates.
- Reference — every constructor, config field, default and error string.
- What authn does not do — the limits, before you design around a capability that isn't there.