Skip to content

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:

go run .
subject="ci-bot" method="apikey"

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:

    id, err := verifier.Verify(context.Background(), "wrong")
panic: authn: unauthenticated

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:

  1. Extract the credential. Pulling the bearer token out of the Authorization header is transport glue you own — authn never looks at a request.
  2. Verify it. One call, one error, one Identity.
  3. Carry the identity forward. ContextWithIdentity puts it on the request context; IdentityFromContext reads 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:

go run .

Try it from another terminal

Without a credential:

curl -i http://127.0.0.1:8080/whoami
HTTP/1.1 401 Unauthorized
...
unauthorized

With the wrong one:

curl -i -H 'Authorization: Bearer nope' http://127.0.0.1:8080/whoami
HTTP/1.1 401 Unauthorized

Identical. No hint about which part was wrong.

With the right one:

curl -H 'Authorization: Bearer s3cr3t' http://127.0.0.1:8080/whoami
hello ci-bot (via apikey)

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
HTTP/1.1 403 Forbidden
HTTP/1.1 200 OK

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.