Authentication

View Source

barrel_mcp provides a pluggable authentication system following OAuth 2.1 patterns as recommended by the MCP specification. Authentication is optional and configurable per HTTP server instance.

Overview

Authentication in barrel_mcp is handled by providers - modules implementing the barrel_mcp_auth behaviour. The library includes several built-in providers:

ProviderUse Case
barrel_mcp_auth_noneNo authentication (default)
barrel_mcp_auth_bearerJWT tokens or opaque Bearer tokens
barrel_mcp_auth_apikeyAPI key authentication
barrel_mcp_auth_basicHTTP Basic authentication

Bearer Token Authentication

The most common pattern for MCP servers, supporting both JWT and opaque tokens.

JWT with HS256

barrel_mcp:start_http(#{
    port => 9090,
    auth => #{
        provider => barrel_mcp_auth_bearer,
        provider_opts => #{
            %% HMAC secret for HS256 signature verification
            secret => <<"your-256-bit-secret-key-here">>,

            %% Optional: Validate issuer claim
            issuer => <<"https://auth.example.com">>,

            %% Optional: Validate audience claim
            audience => <<"https://api.example.com">>,

            %% Optional: Clock skew tolerance in seconds (default: 60)
            clock_skew => 120,

            %% Optional: Custom scope claim name (default: <<"scope">>)
            scope_claim => <<"permissions">>
        },
        %% Optional: Require specific scopes
        required_scopes => [<<"mcp:read">>, <<"mcp:write">>]
    }
}).

JWT with RS256/ES256 (Custom Verifier)

For asymmetric algorithms, provide a custom verifier function:

%% Using jose library for RS256
Verifier = fun(Token) ->
    try
        JWK = jose_jwk:from_pem_file("public_key.pem"),
        case jose_jwt:verify(JWK, Token) of
            {true, {jose_jwt, Claims}, _} -> {ok, Claims};
            {false, _, _} -> {error, invalid_token}
        end
    catch
        _:_ -> {error, invalid_token}
    end
end,

barrel_mcp:start_http(#{
    port => 9090,
    auth => #{
        provider => barrel_mcp_auth_bearer,
        provider_opts => #{verifier => Verifier}
    }
}).

Opaque Tokens (Token Introspection)

For tokens that require server-side validation:

Verifier = fun(Token) ->
    %% Call your auth server's introspection endpoint
    case httpc:request(post, {
        "https://auth.example.com/introspect",
        [{"Authorization", "Bearer " ++ SecretKey}],
        "application/x-www-form-urlencoded",
        "token=" ++ binary_to_list(Token)
    }, [], []) of
        {ok, {{_, 200, _}, _, Body}} ->
            Claims = jsx:decode(list_to_binary(Body), [return_maps]),
            case maps:get(<<"active">>, Claims, false) of
                true -> {ok, Claims};
                false -> {error, invalid_token}
            end;
        _ ->
            {error, invalid_token}
    end
end,

barrel_mcp:start_http(#{
    port => 9090,
    auth => #{
        provider => barrel_mcp_auth_bearer,
        provider_opts => #{verifier => Verifier}
    }
}).

API Key Authentication

Simple and effective for server-to-server communication.

Static Key Map

barrel_mcp:start_http(#{
    port => 9090,
    auth => #{
        provider => barrel_mcp_auth_apikey,
        provider_opts => #{
            keys => #{
                <<"ak_prod_abc123">> => #{
                    subject => <<"service-a">>,
                    scopes => [<<"read">>, <<"write">>],
                    metadata => #{team => <<"platform">>}
                },
                <<"ak_prod_xyz789">> => #{
                    subject => <<"service-b">>,
                    scopes => [<<"read">>]
                }
            }
        }
    }
}).

The recommended format is a peppered HMAC-SHA-256 digest. The pepper is a server-side secret mixed into the hash so a leak of the stored hash table on its own isn't enough to forge keys.

Pepper = <<"random-32-byte-pepper-loaded-from-env">>,
Hash1 = barrel_mcp_auth_apikey:hash_key(<<"ak_prod_abc123">>,
                                         #{pepper => Pepper}),
Hash2 = barrel_mcp_auth_apikey:hash_key(<<"ak_prod_xyz789">>,
                                         #{pepper => Pepper}),

barrel_mcp:start_http(#{
    port => 9090,
    auth => #{
        provider => barrel_mcp_auth_apikey,
        provider_opts => #{
            keys => #{
                Hash1 => #{subject => <<"service-a">>},
                Hash2 => #{subject => <<"service-b">>}
            },
            hash_keys => true,
            pepper => Pepper
        }
    }
}).

Stored values look like <<"hmac-sha256$<base64-encoded-hash>">>.

For verification outside the auth pipeline (config tooling, tests), use barrel_mcp_auth_apikey:verify_key/2 — it does a constant-time comparison and accepts both the new format and legacy unsalted hex SHA-256 digests for one release.

Migrating from legacy SHA-256

hash_key/1 (no pepper) still produces the legacy hex SHA-256 format and is still accepted by the verifier. To migrate:

  1. Generate a pepper and load it from a secret store.
  2. Re-hash each existing key with hash_key/2.
  3. Replace the entries in your keys map.
  4. Drop the legacy entries.

Custom Header Name

barrel_mcp:start_http(#{
    port => 9090,
    auth => #{
        provider => barrel_mcp_auth_apikey,
        provider_opts => #{
            header_name => <<"X-Service-Key">>,  %% Custom header
            keys => #{<<"my-key">> => #{subject => <<"service">>}}
        }
    }
}).

Dynamic Key Validation

Verifier = fun(ApiKey) ->
    case my_db:lookup_api_key(ApiKey) of
        {ok, #{user_id := UserId, scopes := Scopes}} ->
            {ok, #{subject => UserId, scopes => Scopes}};
        not_found ->
            {error, invalid_credentials}
    end
end,

barrel_mcp:start_http(#{
    port => 9090,
    auth => #{
        provider => barrel_mcp_auth_apikey,
        provider_opts => #{verifier => Verifier}
    }
}).

Basic Authentication

HTTP Basic auth - simple but requires TLS in production.

Static Credentials

barrel_mcp:start_http(#{
    port => 9090,
    auth => #{
        provider => barrel_mcp_auth_basic,
        provider_opts => #{
            credentials => #{
                <<"admin">> => <<"secret123">>,
                <<"readonly">> => <<"viewer456">>
            },
            realm => <<"MCP Server">>
        }
    }
}).

Hashed Passwords

hash_password/1,2 defaults to PBKDF2-SHA256 (100k iterations, random 16-byte salt). Stored values look like <<"pbkdf2-sha256$<iters>$<base64(salt)>$<base64(hash)>">>.

%% Hash passwords once, store the resulting binary, use that in
%% the credentials map.
AdminHash = barrel_mcp_auth_basic:hash_password(<<"secret123">>),
UserHash  = barrel_mcp_auth_basic:hash_password(<<"viewer456">>),

barrel_mcp:start_http(#{
    port => 9090,
    auth => #{
        provider => barrel_mcp_auth_basic,
        provider_opts => #{
            credentials => #{
                <<"admin">>    => AdminHash,
                <<"readonly">> => UserHash
            },
            hash_passwords => true
        }
    }
}).

hash_password/2 accepts an options map:

  • algorithmpbkdf2-sha256 (default) or sha256-hex (legacy, for migration only).
  • iterations — PBKDF2 iteration count (default 100000).

The verification path is the public verify_password/2. It accepts the modern format and legacy hex SHA-256 digests for one release; the legacy code path logs a deprecation warning on every match.

With Scopes and Metadata

barrel_mcp:start_http(#{
    port => 9090,
    auth => #{
        provider => barrel_mcp_auth_basic,
        provider_opts => #{
            credentials => #{
                <<"admin">> => #{
                    password => <<"secret123">>,
                    scopes => [<<"read">>, <<"write">>, <<"admin">>],
                    metadata => #{role => <<"administrator">>}
                }
            }
        }
    }
}).

Custom Authentication Provider

Implement the barrel_mcp_auth behaviour for custom authentication:

-module(my_oauth_provider).
-behaviour(barrel_mcp_auth).

-export([init/1, authenticate/2, challenge/2]).

%% Initialize provider state
init(Opts) ->
    ClientId = maps:get(client_id, Opts),
    ClientSecret = maps:get(client_secret, Opts),
    IntrospectUrl = maps:get(introspect_url, Opts),
    {ok, #{
        client_id => ClientId,
        client_secret => ClientSecret,
        introspect_url => IntrospectUrl
    }}.

%% Authenticate a request
authenticate(Request, State) ->
    Headers = maps:get(headers, Request, #{}),
    case barrel_mcp_auth:extract_bearer_token(Headers) of
        {ok, Token} ->
            introspect_token(Token, State);
        {error, no_token} ->
            {error, unauthorized}
    end.

%% Generate challenge response for failed auth
challenge(unauthorized, State) ->
    Realm = maps:get(realm, State, <<"mcp">>),
    {401, #{
        <<"www-authenticate">> => <<"Bearer realm=\"", Realm/binary, "\"">>
    }, <<"{\"error\":\"unauthorized\"}">>};
challenge(invalid_token, _State) ->
    {401, #{
        <<"www-authenticate">> => <<"Bearer error=\"invalid_token\"">>
    }, <<"{\"error\":\"invalid_token\"}">>};
challenge(insufficient_scope, _State) ->
    {403, #{
        <<"www-authenticate">> => <<"Bearer error=\"insufficient_scope\"">>
    }, <<"{\"error\":\"insufficient_scope\"}">>}.

%% Internal: Token introspection
introspect_token(Token, #{introspect_url := Url} = State) ->
    %% Your introspection logic here
    case call_introspection_endpoint(Token, Url, State) of
        {ok, #{<<"active">> := true} = Claims} ->
            {ok, #{
                subject => maps:get(<<"sub">>, Claims),
                scopes => parse_scopes(maps:get(<<"scope">>, Claims, <<>>)),
                claims => Claims
            }};
        _ ->
            {error, invalid_token}
    end.

Accessing Auth Info in Handlers

After successful authentication, auth info is available in the request:

my_tool_handler(Args) ->
    case maps:get(<<"_auth">>, Args, undefined) of
        undefined ->
            %% No auth (using barrel_mcp_auth_none)
            do_something_anonymous();
        #{subject := Subject, scopes := Scopes} = AuthInfo ->
            %% Authenticated request
            case lists:member(<<"admin">>, Scopes) of
                true -> do_admin_action(Subject);
                false -> do_user_action(Subject)
            end
    end.

Error Responses

Authentication failures return proper HTTP status codes and WWW-Authenticate headers:

ErrorStatusDescription
unauthorized401No credentials provided
invalid_token401Token is malformed or signature invalid
expired_token401Token has expired
invalid_credentials401Wrong username/password or API key
insufficient_scope403Token lacks required scopes

OAuth grant flows

barrel_mcp_client ships three grants plus a registration pre-step. Pick by who is in the loop and when:

Authorization Code + PKCE — interactive

For flows where a real user authorises the host. Browser redirect, PKCE prevents code interception, refresh on 401.

User    Host                   AS                  MCP server
   click "connect"                                    
  redirect + PKCE                                 
                           
     login + consent                                 
                           
     exchange_code +                                 
     code_verifier                                   
                           
     access_token +                                  
     refresh_token                                   
                           
     Bearer access_token                              
    
           (on 401: refresh_token grant)              
auth => {oauth, #{
    access_token   => <<"...">>,            % required
    refresh_token  => <<"...">>,            % optional, enables refresh
    token_endpoint => <<"https://idp/oauth/token">>,
    client_id      => <<"...">>,
    client_secret  => <<"...">>,            % optional confidential client
    resource       => <<"https://mcp/...">>,
    scopes         => [<<"mcp.read">>, <<"mcp.write">>]
}}

The host drives the browser dance and feeds the resulting tokens in. The library handles the refresh.

Client Credentials — unattended (M2M)

For agent hosts running without a human. The host already has its own credentials.

Host                              AS                  MCP server
   POST /token                                           
   grant=client_credentials                              
   + Basic <client_id:secret>                            
                        
   access_token                                          
                        
   Bearer access_token                                    
 
              (on 401: re-acquire via same grant)         
auth => {oauth_client_credentials, #{
    token_endpoint   => <<"https://idp/oauth/token">>,
    client_id        => <<"my-agent-host">>,
    client_secret    => <<"...">>,          % OR client_assertion (JWT)
    resource         => <<"https://mcp/...">>,
    scopes           => [<<"mcp.read">>]
}}

Eager fetch on init — a misconfigured client fails up front. Re-acquires via the same grant on 401. No refresh_token is involved.

Enterprise-Managed Authorization — SSO chain

For SSO-driven hosts. The user already has a session at the org IdP; their identity flows into a short-lived MCP access token without re-prompting. Two-step chain (RFC 8693 → RFC 7523).

User    Host        IdP             AS                MCP server
   active SSO session                                     
   ID Token                                               
                                               
             POST /token                                 
             grant=token-exchange                        
             subject_token=<id_token>                     
             audience=<AS issuer>                        
             resource=<MCP url>                          
                                   
             ID-JAG (signed JWT)                         
                                   
             POST /token                                 
             grant=jwt-bearer                            
             assertion=<ID-JAG>                          
                                   
              access_token                               
                                   
              Bearer access_token                         
            
             (on 401: re-walk the chain)                 
             (id_token expires                          
              {error, subject_token_expired})            
auth => {oauth_enterprise, #{
    idp_token_endpoint => <<"https://idp/oauth/token">>,
    as_token_endpoint  => <<"https://as/oauth/token">>,
    client_id          => <<"...">>,
    client_secret      => <<"...">>,        % OR client_assertion
    subject_token      => <IdToken>,        % from IdP, opaque
    subject_token_type =>
        <<"urn:ietf:params:oauth:token-type:id_token">>, % or saml2
    audience           => <<"https://as">>,
    resource           => <<"https://mcp/...">>,
    scopes             => [<<"mcp.read">>]
}}

The library treats subject_token as opaque — both OIDC and SAML modes hit the same code path. The browser flow at the IdP stays a host concern.

Dynamic Client Registration — pre-step

Not a grant. Used before any of the others when the host doesn't have a client_id yet (fresh deployment, distributed host, dev sandbox). RFC 7591.

Host                                    AS
   POST /register                       
   { client_name, redirect_uris,        
     grant_types, ... }                 
 
   { client_id, client_secret?,         
     client_id_issued_at, ... }         
 
{ok, Info} = barrel_mcp_client_auth_oauth:register_client(
    <<"https://idp/oauth/register">>,
    #{<<"client_name">> => <<"my-host">>, ...}),
ClientId     = maps:get(<<"client_id">>, Info),
ClientSecret = maps:get(<<"client_secret">>, Info, undefined).

For protected registration endpoints (RFC 7591 section 3) pass the AS-issued initial access token via register_client/3:

{ok, Info} = barrel_mcp_client_auth_oauth:register_client(
    <<"https://idp/oauth/register">>,
    #{<<"client_name">> => <<"my-host">>, ...},
    #{initial_access_token => <<"...">>}).

Feed the returned credentials into one of the grants above. The library does not persist them; that's a host concern.

Where they overlap on the wire

All grants hit the same OAuth-server token endpoint with application/x-www-form-urlencoded bodies. Confidential clients authenticate with HTTP Basic; private_key_jwt clients pass a client_assertion instead. RFC 8707 resource is attached on every grant. The MCP 2025-11-25 auth sub-spec layers RFC 9728 PRM on top so any of the grants can be auto-discovered from a 401 response.

When to pick which

SituationUse
Real user, browser available, host wants their identityauth_code ({oauth, ...})
Background agent / cron / unattended hostclient_credentials ({oauth_client_credentials, ...})
Enterprise SSO; user identity must flow to MCPenterprise_managed ({oauth_enterprise, ...})
No client_id yetregister_client/2 first, then one of the above

OAuth Protected Resource Metadata (RFC 9728)

For OAuth-protected deployments, MCP clients auto-discover the authorization server by:

  1. Hitting any endpoint and receiving a 401 with WWW-Authenticate: Bearer ... resource_metadata="<URL>".
  2. Following the URL to fetch the RFC 9728 Protected Resource Metadata document.
  3. Reading authorization_servers from the document and completing the OAuth dance against one of them.

The server side of this loop is opt-in via a resource_metadata option on barrel_mcp:start_http_stream/1 (and start_http/1):

{ok, _} = barrel_mcp:start_http_stream(#{
    port => 8080,
    auth => #{provider => barrel_mcp_auth_bearer,
              provider_opts => #{secret => Secret}},
    resource_metadata => #{
        resource              => <<"http://localhost:8080/mcp">>,
        authorization_servers => [<<"https://idp.example.com">>]
    }
}).

When set:

  • /.well-known/oauth-protected-resource is served by the HTTP transport as a JSON metadata document.
  • The bearer challenge on 401 emits resource_metadata="<absolute PRM URL>". The PRM URL is derived from resource by default; pass metadata_url => <<"https://...">> in the option map to override.

The audience-claim string in state.resource (used for token verification by barrel_mcp_auth_bearer) is unaffected; only the wire emission of WWW-Authenticate changed.

The client side is implemented by barrel_mcp_client_auth_oauth:parse_www_authenticate/1 and discover_protected_resource/1 — together with the server side above, the MCP authorization sub-spec discovery flow works end-to-end.

Dynamic Client Registration (RFC 7591)

Hosts that don't have a pre-issued client_id for the target authorization server can register one programmatically. The AS metadata document advertises the endpoint via registration_endpoint.

{ok, Info} = barrel_mcp_client_auth_oauth:register_client(
    <<"https://idp.example.com/oauth/register">>,
    #{<<"client_name">>   => <<"my-mcp-host">>,
      <<"redirect_uris">> => [<<"http://localhost:5173/callback">>],
      <<"grant_types">>   => [<<"authorization_code">>,
                              <<"refresh_token">>],
      <<"response_types">> => [<<"code">>],
      <<"token_endpoint_auth_method">> => <<"none">>}).

%% Info now contains:
%%   #{<<"client_id">>           => <<"...">>,
%%     <<"client_secret">>       => <<"...">>,   % if confidential
%%     <<"client_id_issued_at">> => 1700000000,  % UNIX epoch
%%     ...}

Feed the returned credentials into a subsequent {oauth, ...} / {oauth_client_credentials, ...} connect spec. This stays a standalone exchanger — the library doesn't persist the issued credentials. That's a host concern (file, DB, secret manager).

Security Best Practices

  1. Always use TLS in production
  2. Hash stored credentials using the provided hash functions
  3. Use short-lived tokens with refresh capability
  4. Validate audience claims to prevent token misuse
  5. Implement rate limiting at the transport layer
  6. Log authentication failures for security monitoring