Skip to main content

Overview

HandsAI supports multiple authentication methods to connect with virtually any REST API. Authentication is configured at the Provider level and automatically applied to all tools under that provider. All credentials are encrypted at rest using Jasypt before being stored in SQLite.

Supported Authentication Types

HandsAI supports these authentication methods:
NONE
enum
No authentication required (public APIs)
API_KEY
enum
Static API key sent in header, query parameter, or request body
BEARER_TOKEN
enum
Bearer token authentication (typically sent in Authorization header)
BASIC_AUTH
enum
HTTP Basic Authentication (username:password encoded in Base64)

AuthenticationTypeEnum

public enum AuthenticationTypeEnum {
    NONE,
    API_KEY,
    BEARER_TOKEN,
    BASIC_AUTH
}

API Key Locations

When using API_KEY or BEARER_TOKEN authentication, you can specify where the credential should be sent:
HEADER
enum
Send in HTTP header (most common)Example: Authorization: Bearer sk_abc123
QUERY_PARAMETER
enum
Append to URL as query parameterExample: ?api_key=sk_abc123
IN_BODY
enum
Include in request body (for POST/PUT/PATCH requests)Example: {"api_key": "sk_abc123", "query": "..."}

ApiKeyLocationEnum

public enum ApiKeyLocationEnum {
    NONE,
    HEADER,
    QUERY_PARAMETER,
    IN_BODY
}

Authentication Configuration Examples

Example 1: GitHub (Bearer Token in Header)

{
  "name": "GitHub REST API",
  "baseUrl": "https://api.github.com",
  "authenticationType": "BEARER_TOKEN",
  "apiKeyLocation": "HEADER",
  "apiKeyName": "Authorization",
  "apiKeyValue": "ghp_abc123def456",
  "customHeaders": {
    "Accept": "application/vnd.github+json",
    "X-GitHub-Api-Version": "2022-11-28",
    "User-Agent": "HandsAI/1.0"
  }
}
Requests to GitHub include:
Authorization: Bearer ghp_abc123def456
Accept: application/vnd.github+json
X-GitHub-Api-Version: 2022-11-28
User-Agent: HandsAI/1.0

Example 2: Tavily (API Key in Body)

{
  "name": "Tavily AI Search",
  "baseUrl": "https://api.tavily.com",
  "authenticationType": "API_KEY",
  "apiKeyLocation": "IN_BODY",
  "apiKeyName": "api_key",
  "apiKeyValue": "tvly-abc123",
  "customHeaders": {
    "Content-Type": "application/json"
  }
}
Requests to Tavily automatically inject the API key in the body:
{
  "api_key": "tvly-abc123",
  "query": "latest AI news",
  "search_depth": "basic"
}

Example 3: Resend (Bearer Token in Header)

{
  "name": "Resend API",
  "baseUrl": "https://api.resend.com",
  "authenticationType": "BEARER_TOKEN",
  "apiKeyLocation": "HEADER",
  "apiKeyName": "Authorization",
  "apiKeyValue": "re_abc123def456",
  "customHeaders": {
    "Content-Type": "application/json",
    "User-Agent": "HandsAI/1.0"
  }
}
Requests to Resend include:
Authorization: Bearer re_abc123def456
Content-Type: application/json

Example 4: Custom API (API Key in Query Parameter)

{
  "name": "Custom Weather API",
  "baseUrl": "https://api.weather.example.com",
  "authenticationType": "API_KEY",
  "apiKeyLocation": "QUERY_PARAMETER",
  "apiKeyName": "apikey",
  "apiKeyValue": "abc123def456"
}
Requests append the key to the URL:
GET https://api.weather.example.com/forecast?city=London&apikey=abc123def456

OAuth2 Token Refresh (Dynamic Authentication)

Some APIs require fetching a short-lived access token before making requests. HandsAI supports Dynamic Authentication for this scenario.

How Dynamic Auth Works

  1. Configure Token Endpoint: Specify the OAuth2 token URL and credentials
  2. First Request: HandsAI fetches the access token
  3. Token Caching: Token is cached for 5 minutes (default TTL)
  4. Automatic Refresh: When the token expires, HandsAI fetches a new one
  5. Tool Execution: The fresh token is used for API calls

DynamicTokenManager

HandsAI uses DynamicTokenManager to handle OAuth2 token lifecycle:
@Service
public class DynamicTokenManager {

    private final Map<Long, CachedToken> tokenCache = new ConcurrentHashMap<>();
    private static final long TOKEN_TTL_SECONDS = 300; // 5 minutes

    public String getToken(ApiProvider provider) {
        if (!provider.isDynamicAuth()) {
            return null; // Not using dynamic auth
        }

        CachedToken cachedToken = tokenCache.get(provider.getId());
        if (cachedToken != null && cachedToken.expiresAt().isAfter(Instant.now())) {
            log.debug("Returning cached dynamic token for provider {}", provider.getId());
            return cachedToken.token();
        }

        log.info("Fetching new dynamic token for provider {}", provider.getId());
        String newToken = fetchNewToken(provider);

        tokenCache.put(provider.getId(), 
            new CachedToken(newToken, Instant.now().plusSeconds(TOKEN_TTL_SECONDS)));
        return newToken;
    }

    public void invalidateToken(Long providerId) {
        log.info("Invalidating dynamic token cache for provider {}", providerId);
        tokenCache.remove(providerId);
    }

    private record CachedToken(String token, Instant expiresAt) {}
}

Token Fetch Flow

When fetching a new token, DynamicTokenManager performs these steps:
private String fetchNewToken(ApiProvider provider) {
    try {
        RestClient client = restClientBuilder.baseUrl(provider.getDynamicAuthUrl()).build();
        HttpMethod method = provider.getDynamicAuthMethod() == DynamicAuthMethodEnum.GET 
            ? HttpMethod.GET 
            : HttpMethod.POST;

        // Parse and decrypt payload
        Map<String, Object> payloadMap = null;
        if (provider.getDynamicAuthPayload() != null && !provider.getDynamicAuthPayload().isBlank()) {
            payloadMap = objectMapper.readValue(provider.getDynamicAuthPayload(),
                    new TypeReference<Map<String, Object>>() {});
            
            // Decrypt encrypted values
            for (Map.Entry<String, Object> entry : payloadMap.entrySet()) {
                if (entry.getValue() instanceof String strVal && !strVal.isBlank()) {
                    entry.setValue(encryptionService.decrypt(strVal));
                }
            }
        }

        String finalUri = provider.getDynamicAuthUrl();
        DynamicAuthPayloadLocationEnum location = provider.getDynamicAuthPayloadLocation();
        
        // Handle payload location (QUERY_PARAMETERS, HEADERS, BODY)
        RestClient.RequestBodySpec requestSpec = buildAuthRequest(client, method, finalUri, payloadMap, location, provider);

        String responseBody = requestSpec.retrieve().body(String.class);

        // Extract token from response using JSON path
        return extractTokenFromResponse(responseBody, provider.getDynamicAuthTokenExtractionPath());

    } catch (Exception e) {
        log.error("Failed to fetch dynamic token for provider {}", provider.getId(), e);
        throw new ToolExecutionException("Failed to fetch dynamic auth token: " + e.getMessage());
    }
}

Dynamic Auth Configuration Fields

isDynamicAuth
boolean
default:"false"
Enable dynamic token fetching
dynamicAuthUrl
string
OAuth2 token endpoint URL (e.g., https://bsky.social/xrpc/com.atproto.server.createSession)
dynamicAuthMethod
enum
HTTP method to fetch token: GET or POST
dynamicAuthPayload
string
JSON payload with credentials (encrypted before storage)Example: {"identifier": "[email protected]", "password": "app_password"}
dynamicAuthPayloadType
enum
Payload format: JSON or FORM_DATA
dynamicAuthPayloadLocation
enum
Where to send payload: BODY, HEADERS, or QUERY_PARAMETERS
dynamicAuthTokenExtractionPath
string
JSON path to extract token from response (e.g., accessJwt or data.token)
dynamicAuthInvalidationKeywords
string
Comma-separated keywords that indicate token expiration (e.g., ExpiredToken,AuthenticationRequired)

Example: Bluesky OAuth2 Flow

{
  "name": "Bluesky API",
  "baseUrl": "https://bsky.social/xrpc",
  "authenticationType": "BEARER_TOKEN",
  "apiKeyLocation": "HEADER",
  "apiKeyName": "Authorization",
  "isDynamicAuth": true,
  "dynamicAuthUrl": "https://bsky.social/xrpc/com.atproto.server.createSession",
  "dynamicAuthMethod": "POST",
  "dynamicAuthPayloadType": "JSON",
  "dynamicAuthPayloadLocation": "BODY",
  "dynamicAuthPayload": "{\"identifier\":\"your_handle.bsky.social\",\"password\":\"your_app_password\"}",
  "dynamicAuthTokenExtractionPath": "accessJwt",
  "dynamicAuthInvalidationKeywords": "ExpiredToken,AuthenticationRequired"
}
Token Fetch Request:
POST https://bsky.social/xrpc/com.atproto.server.createSession
Content-Type: application/json

{
  "identifier": "your_handle.bsky.social",
  "password": "your_app_password"
}
Token Response:
{
  "did": "did:plc:abc123",
  "handle": "your_handle.bsky.social",
  "email": "[email protected]",
  "accessJwt": "eyJhbGciOiJIUzI1NiIs...",
  "refreshJwt": "eyJhbGciOiJIUzI1NiIs..."
}
HandsAI extracts accessJwt and uses it for subsequent requests:
GET https://bsky.social/xrpc/app.bsky.feed.getTimeline?limit=10
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...

Token Cache and Expiration

Tokens are cached in-memory with a 5-minute TTL:
private record CachedToken(String token, Instant expiresAt) {}
Behavior:
  • Cache Hit: If token exists and expiresAt > now(), use cached token
  • Cache Miss: Fetch new token, cache it for 5 minutes
  • Manual Invalidation: Call invalidateToken(providerId) to force refresh
This prevents excessive token requests while ensuring fresh credentials.

Credential Encryption (EncryptionService)

All sensitive credentials are encrypted before storage using Jasypt (Java Simplified Encryption).

EncryptionService Interface

public interface EncryptionService {
    String encrypt(String data);
    String decrypt(String encryptedData);
}

Implementation with Jasypt

@Service
@RequiredArgsConstructor
public class EncryptionServiceImpl implements EncryptionService {

    private final StringEncryptor stringEncryptor;

    @Override
    public String encrypt(String data) {
        if (data == null) {
            return null;
        }
        return stringEncryptor.encrypt(data);
    }

    @Override
    public String decrypt(String encryptedData) {
        if (encryptedData == null) {
            return null;
        }
        return stringEncryptor.decrypt(encryptedData);
    }
}

What Gets Encrypted

  1. API Keys and Tokens: provider.apiKeyValue
  2. Dynamic Auth Credentials: provider.dynamicAuthPayload
  3. OAuth2 Secrets: Any sensitive values in custom headers or payloads

Encryption Flow

On Save (Provider or Tool Creation):
// Before saving to database
String encryptedKey = encryptionService.encrypt("ghp_abc123def456");
provider.setApiKeyValue(encryptedKey); // Store encrypted value
apiProviderRepository.save(provider);
On Use (Tool Execution):
// When making API request
String decryptedKey = encryptionService.decrypt(provider.getApiKeyValue());
requestSpec.header("Authorization", "Bearer " + decryptedKey);

Jasypt Configuration

Jasypt is configured via environment variable or application.properties:
jasypt.encryptor.password=${JASYPT_ENCRYPTOR_PASSWORD:defaultSecretKey}
jasypt.encryptor.algorithm=PBEWithMD5AndDES
Production Setup:
export JASYPT_ENCRYPTOR_PASSWORD="your-strong-secret-key"
java -jar handsai.jar
Security Best Practice: Use a strong, unique encryption key in production. Store it securely (e.g., in a secrets manager, not in version control).

GraalVM Native Image Compatibility

Jasypt uses reflection, which requires explicit hints for GraalVM compilation. HandsAI registers these in NativeHintsConfig.java:
@Override
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
    // Register Jasypt classes for reflection
    hints.reflection().registerType(
        TypeReference.of("org.jasypt.encryption.pbe.StandardPBEStringEncryptor"),
        MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS,
        MemberCategory.INVOKE_PUBLIC_METHODS
    );
    // ... other registrations
}
This ensures HandsAI can be compiled as a native executable while maintaining encryption functionality.

Authentication in Action

Here’s how authentication flows through a complete tool execution:

Step 1: Tool Discovery

GET http://localhost:8080/mcp/tools/list
Response includes tools (authentication not exposed):
{
  "jsonrpc": "2.0",
  "result": {
    "tools": [
      {
        "name": "github-create-issue",
        "description": "Creates a new issue in a GitHub repository.",
        "inputSchema": { ... }
      }
    ]
  }
}

Step 2: Tool Execution Request

POST http://localhost:8080/mcp/tools/call
{
  "jsonrpc": "2.0",
  "method": "tools/call",
  "params": {
    "name": "github-create-issue",
    "arguments": {
      "owner": "facebook",
      "repo": "react",
      "title": "Bug in useEffect"
    }
  },
  "id": "req-1"
}

Step 3: Authentication Resolution

ToolExecutionService retrieves the tool and provider:
ApiTool tool = toolCacheManager.getCachedTool("github-create-issue")
    .orElseThrow(() -> new ResourceNotFoundException("Tool not found"));

ApiProvider provider = tool.getProvider();

Step 4: Token Retrieval

If using dynamic auth:
String token;
if (provider.isDynamicAuth()) {
    token = dynamicTokenManager.getToken(provider); // Fetch or use cached token
} else {
    token = encryptionService.decrypt(provider.getApiKeyValue()); // Decrypt static credential
}

Step 5: Request Construction

Build the authenticated request:
RestClient.RequestBodySpec requestSpec = restClient
    .method(HttpMethod.POST)
    .uri("https://api.github.com/repos/facebook/react/issues")
    .header("Authorization", "Bearer " + token)
    .header("Accept", "application/vnd.github+json")
    .contentType(MediaType.APPLICATION_JSON)
    .body(arguments);

Step 6: API Call and Response

String response = requestSpec.retrieve().body(String.class);
The response is wrapped in MCP format and returned to the AI agent.

Security Considerations

Encryption at Rest

All credentials encrypted with Jasypt before storage in SQLite

SSRF Protection

Validates URLs to prevent internal network access

Token Caching

Short-lived cache (5 min) minimizes credential exposure

No Credential Logging

Credentials never appear in application logs

Best Practices

  1. Use Environment Variables: Store JASYPT_ENCRYPTOR_PASSWORD securely
  2. Rotate Credentials: Regularly update API keys and tokens
  3. Enable Dynamic Auth: Prefer short-lived tokens over long-lived keys
  4. Limit Tool Scope: Only grant minimum required permissions to API tokens
  5. Monitor Usage: Check analytics for suspicious tool execution patterns

Real-World Authentication Examples

Here are complete authentication configurations from HandsAI’s use case library:

Resend API (Static Bearer Token)

{
  "authenticationType": "BEARER_TOKEN",
  "apiKeyLocation": "HEADER",
  "apiKeyName": "Authorization",
  "apiKeyValue": "re_abc123"
}

Tavily Search (API Key in Body)

{
  "authenticationType": "API_KEY",
  "apiKeyLocation": "IN_BODY",
  "apiKeyName": "api_key",
  "apiKeyValue": "tvly-abc123"
}

Bluesky (OAuth2 with Dynamic Auth)

{
  "authenticationType": "BEARER_TOKEN",
  "apiKeyLocation": "HEADER",
  "apiKeyName": "Authorization",
  "isDynamicAuth": true,
  "dynamicAuthUrl": "https://bsky.social/xrpc/com.atproto.server.createSession",
  "dynamicAuthMethod": "POST",
  "dynamicAuthPayloadType": "JSON",
  "dynamicAuthPayloadLocation": "BODY",
  "dynamicAuthPayload": "{\"identifier\":\"user.bsky.social\",\"password\":\"app_pwd\"}",
  "dynamicAuthTokenExtractionPath": "accessJwt"
}

Google Jules (API Key in Header)

{
  "authenticationType": "API_KEY",
  "apiKeyLocation": "HEADER",
  "apiKeyName": "x-goog-api-key",
  "apiKeyValue": "AIza..."
}

Next Steps

MCP Protocol

Learn how authentication integrates with MCP

Tool Registry

Understand how tools and providers are stored

Import Use Cases

Import pre-configured APIs with authentication

Security

Advanced security best practices

Build docs developers (and LLMs) love