ggicci/caddy-jwt

πŸ†” Caddy Module JWT Authentication

Go

125

169 commits

updated Aug 21, 2026

See the code

README

caddy-jwt

Go Workflow codecov Go Report Card Go Reference

A Caddy HTTP Module - who Facilitates JWT Authentication

This module fulfilled http.handlers.authentication middleware as a provider named jwt.

Documentation

Install

Build this module with caddy at Caddy's official download site. Or build it with xcaddy locally by yourself:

# A caddy binary will be produced in your current directory.
xcaddy build --with github.com/ggicci/caddy-jwt

Requirements for a local build:

caddy-jwt VersionGo versionCaddy Version
v1.1.1>=1.25.0>=2.10.1
v1.1.0>=1.20.0>=2.8.0

Sample Caddyfile

{
	order jwtauth before basicauth
}

api.example.com {
	jwtauth {
		sign_key TkZMNSowQmMjOVU2RUB0bm1DJkU3U1VONkd3SGZMbVk=
		sign_alg HS256
		jwk_url https://api.example.com/jwk/keys
		from_query access_token token
		from_header X-Api-Token
		from_cookies user_session
		issuer_whitelist https://api.example.com
		audience_whitelist https://api.example.io https://learn.example.com
		user_claims aud uid user_id username login
		meta_claims "IsAdmin->is_admin" "settings.payout.paypal.enabled->is_paypal_enabled"
		log_level debug
	}
	reverse_proxy http://172.16.0.14:8080
}

NOTE:

  1. If you were using symmetric signing algorithms, e.g. HS256, encode your key bytes in base64 format as sign_key's value.
TkZMNSowQmMjOVU2RUB0bm1DJkU3U1VONkd3SGZMbVk=
  1. If you were using asymmetric signing algorithms, e.g. RS256, encode your public key in x.509 PEM format as sign_key's value.
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArzekF0pqttKNJMOiZeyt
RdYiabdyy/sdGQYWYJPGD2Q+QDU9ZqprDmKgFOTxUy/VUBnaYr7hOEMBe7I6dyaS
5G0EGr8UXAwgD5Uvhmz6gqvKTV+FyQfw0bupbcM4CdMD7wQ9uOxDdMYm7g7gdGd6
SSIVvmsGDibBI9S7nKlbcbmciCmxbAlwegTYSHHLjwWvDs2aAF8fxeRfphwQZKkd
HekSZ090/c2V4i0ju2M814QyGERMoq+cSlmikCgRWoSZeWOSTj+rAZJyEAzlVL4z
8ojzOpjmxw6pRYsS0vYIGEDuyiptf+ODC8smTbma/p3Vz+vzyLWPfReQY2RHtpUe
hwIDAQAB
-----END PUBLIC KEY-----
  1. If you were using JWK, configure jwk_url and leave sign_key unset.

  2. caddy-jwt will determine the signing algorithm by looking into the following values:

    1. alg value in the JWT header;
    2. alg value of the matched JWK if using JWK;
    3. value of the sign_alg config.
  3. The priority of from_xxx is from_query > from_header > from_cookies.

  4. Bypass the verification by turning on skip_verification option, #85.

  5. Instead of specifying the sign_key directly as a value, you can use a feature introduced in Caddy v2.8.0 to load it from a file using sign_key {file./path/to/sign_key.txt}.

  6. log_level controls the level of the user authenticated log line emitted on every successfully authenticated request. It accepts debug, info (the default), warn, or error. Production services typically want a single log line per request rather than one per middleware, so setting this to debug lets you fold this message into your access log's per-request debug output instead of duplicating it at info.

How to do integration test of caddy-jwt locally?

For caddy-jwt users, we assume you've already got a custom caddy binary built with our caddy-jwt plugin. Then you can run the test:

echo '{
        order jwtauth before basicauth
}

:8080 {
        jwtauth {
                sign_key TkZMNSowQmMjOVU2RUB0bm1DJkU3U1VONkd3SGZMbVk=
                sign_alg HS256
                from_query access_token token
                from_header X-Api-Token
                from_cookies user_session
                user_claims aud uid user_id username login
        }

        respond "User authenticated with ID: {http.auth.user.id}"
}' > /tmp/caddy-jwt-test.Caddyfile

# ./caddy is your custom caddy built, see Install section above
./caddy run --config /tmp/caddy-jwt-test.Caddyfile

# This token won't expire until year 2285.
TEST_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5NTU4OTI2NzAsImp0aSI6IjgyMjk0YTYzLTk2NjAtNGM2Mi1hOGE4LTVhNjI2NWVmY2Q0ZSIsInN1YiI6IjM0MDYzMjc5NjM1MTY5MzIiLCJpc3MiOiJodHRwczovL2FwaS5leGFtcGxlLmNvbSIsImF1ZCI6WyJodHRwczovL2FwaS5leGFtcGxlLmlvIl0sInVzZXJuYW1lIjoiZ2dpY2NpIn0.O8kvRO9y6xQO3AymqdFE7DDqLRBQhkntf78O9kF71F8

curl -v "http://localhost:8080?access_token=${TEST_TOKEN}"
# You should see
# 1. caddy log:
#   http.authentication.providers.jwt       user authenticated      {"token_string": "eyJhbGciOiJIUzI1…Qhkntf78O9kF71F8", "user_claim": "username", "id": "ggicci"}
#
# 2. request response (curl command output):
# User Authenticated with ID: ggicci

# And the following command should also work:
curl -v -H"X-Api-Token: ${TEST_TOKEN}" "http://localhost:8080"
curl -v -H"Authorization: Bearer ${TEST_TOKEN}" "http://localhost:8080"

NOTE: you can decode the ${TEST_TOKEN} above at jwt.io to get human readable payload as follows:

{
  "exp": 9955892670,
  "jti": "82294a63-9660-4c62-a8a8-5a6265efcd4e",
  "sub": "3406327963516932",
  "iss": "https://api.example.com",
  "aud": ["https://api.example.io"],
  "username": "ggicci"
}

For caddy-jwt developers, you need to clone this repo, and start the caddy server in the repo folder:

git clone https://github.com/ggicci/caddy-jwt.git
cd caddy-jwt

# Build a caddy with this module and run an example server at localhost.
xcaddy run --config /tmp/caddy-jwt-test.Caddyfile

Any local code changes should reflect immediately.

How caddy-jwt works?

Module caddy-jwt behaves like a "JWT Validator". The authentication flow is:

   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚Extract token fromβ”‚
   β”‚  1. query        β”‚
   β”‚  2. header       β”‚
   β”‚  3. cookies      β”‚
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
            β”‚
    β”Œβ”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β”‚     is valid?     β”‚
    β”‚  using `sign_key` β”‚
    β”‚ or validation is  β”‚
    β”‚     disabled      β”œβ”€NO───────┐
    β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜          β”‚
            β”‚YES                   β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”          β”‚
β”‚Populate {http.user.id}β”‚          β”‚
β”‚  by `user_claims`     β”‚          β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜          β”‚
            β”‚                      β”‚
 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”          β”‚
 β”‚is {http.user.id} set?β”œβ”€β”€NO(empty)
 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜       β”‚  β”‚
            β”‚YES(non-empty)     β”‚  β”‚
 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”       β”‚  β”‚
 β”‚Populate {http.user.*}β”‚       β”‚  β”‚
 β”‚   by `meta_claims`   β”‚       β”‚  β”‚
 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜       β”‚  β”‚
            β”‚                   β”‚  β”‚
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β–Όβ”€β”€β–Όβ”€β”€β”€β”€β”€β”
   β”‚   Authenticated   β”‚ β”‚Unauthenticatedβ”‚
   β”‚ Continue to Caddy β”‚ β”‚      401      β”‚
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

flowchart by https://asciiflow.com/

FAQ

Q1: How to deal with 401 responses on OPTIONS requests? (CORS related)

It should be handled separately by Caddy. Please read #24 for more details.

Q2: What to note when using a public key as the value of sign_key in Caddyfile?

Using multi-line content in a directive should be quoted as Caddy's documentation says. And the public key should be represented in PKCS#1 PEM format. Here's a simple command to derive such a public key from an RSA private key: openssl rsa -in input.rsa -pubout. Related: #36.

References

authentication
caddy-authentication
caddy-module
jwt
jwt-authentication

ggicci/caddy-jwt

πŸ†” Caddy Module JWT Authentication

Go

125

169 commits

updated Aug 21, 2026

See the code

README

caddy-jwt

Go Workflow codecov Go Report Card Go Reference

A Caddy HTTP Module - who Facilitates JWT Authentication

This module fulfilled http.handlers.authentication middleware as a provider named jwt.

Documentation

Install

Build this module with caddy at Caddy's official download site. Or build it with xcaddy locally by yourself:

# A caddy binary will be produced in your current directory.
xcaddy build --with github.com/ggicci/caddy-jwt

Requirements for a local build:

caddy-jwt VersionGo versionCaddy Version
v1.1.1>=1.25.0>=2.10.1
v1.1.0>=1.20.0>=2.8.0

Sample Caddyfile

{
	order jwtauth before basicauth
}

api.example.com {
	jwtauth {
		sign_key TkZMNSowQmMjOVU2RUB0bm1DJkU3U1VONkd3SGZMbVk=
		sign_alg HS256
		jwk_url https://api.example.com/jwk/keys
		from_query access_token token
		from_header X-Api-Token
		from_cookies user_session
		issuer_whitelist https://api.example.com
		audience_whitelist https://api.example.io https://learn.example.com
		user_claims aud uid user_id username login
		meta_claims "IsAdmin->is_admin" "settings.payout.paypal.enabled->is_paypal_enabled"
		log_level debug
	}
	reverse_proxy http://172.16.0.14:8080
}

NOTE:

  1. If you were using symmetric signing algorithms, e.g. HS256, encode your key bytes in base64 format as sign_key's value.
TkZMNSowQmMjOVU2RUB0bm1DJkU3U1VONkd3SGZMbVk=
  1. If you were using asymmetric signing algorithms, e.g. RS256, encode your public key in x.509 PEM format as sign_key's value.
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArzekF0pqttKNJMOiZeyt
RdYiabdyy/sdGQYWYJPGD2Q+QDU9ZqprDmKgFOTxUy/VUBnaYr7hOEMBe7I6dyaS
5G0EGr8UXAwgD5Uvhmz6gqvKTV+FyQfw0bupbcM4CdMD7wQ9uOxDdMYm7g7gdGd6
SSIVvmsGDibBI9S7nKlbcbmciCmxbAlwegTYSHHLjwWvDs2aAF8fxeRfphwQZKkd
HekSZ090/c2V4i0ju2M814QyGERMoq+cSlmikCgRWoSZeWOSTj+rAZJyEAzlVL4z
8ojzOpjmxw6pRYsS0vYIGEDuyiptf+ODC8smTbma/p3Vz+vzyLWPfReQY2RHtpUe
hwIDAQAB
-----END PUBLIC KEY-----
  1. If you were using JWK, configure jwk_url and leave sign_key unset.

  2. caddy-jwt will determine the signing algorithm by looking into the following values:

    1. alg value in the JWT header;
    2. alg value of the matched JWK if using JWK;
    3. value of the sign_alg config.
  3. The priority of from_xxx is from_query > from_header > from_cookies.

  4. Bypass the verification by turning on skip_verification option, #85.

  5. Instead of specifying the sign_key directly as a value, you can use a feature introduced in Caddy v2.8.0 to load it from a file using sign_key {file./path/to/sign_key.txt}.

  6. log_level controls the level of the user authenticated log line emitted on every successfully authenticated request. It accepts debug, info (the default), warn, or error. Production services typically want a single log line per request rather than one per middleware, so setting this to debug lets you fold this message into your access log's per-request debug output instead of duplicating it at info.

How to do integration test of caddy-jwt locally?

For caddy-jwt users, we assume you've already got a custom caddy binary built with our caddy-jwt plugin. Then you can run the test:

echo '{
        order jwtauth before basicauth
}

:8080 {
        jwtauth {
                sign_key TkZMNSowQmMjOVU2RUB0bm1DJkU3U1VONkd3SGZMbVk=
                sign_alg HS256
                from_query access_token token
                from_header X-Api-Token
                from_cookies user_session
                user_claims aud uid user_id username login
        }

        respond "User authenticated with ID: {http.auth.user.id}"
}' > /tmp/caddy-jwt-test.Caddyfile

# ./caddy is your custom caddy built, see Install section above
./caddy run --config /tmp/caddy-jwt-test.Caddyfile

# This token won't expire until year 2285.
TEST_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5NTU4OTI2NzAsImp0aSI6IjgyMjk0YTYzLTk2NjAtNGM2Mi1hOGE4LTVhNjI2NWVmY2Q0ZSIsInN1YiI6IjM0MDYzMjc5NjM1MTY5MzIiLCJpc3MiOiJodHRwczovL2FwaS5leGFtcGxlLmNvbSIsImF1ZCI6WyJodHRwczovL2FwaS5leGFtcGxlLmlvIl0sInVzZXJuYW1lIjoiZ2dpY2NpIn0.O8kvRO9y6xQO3AymqdFE7DDqLRBQhkntf78O9kF71F8

curl -v "http://localhost:8080?access_token=${TEST_TOKEN}"
# You should see
# 1. caddy log:
#   http.authentication.providers.jwt       user authenticated      {"token_string": "eyJhbGciOiJIUzI1…Qhkntf78O9kF71F8", "user_claim": "username", "id": "ggicci"}
#
# 2. request response (curl command output):
# User Authenticated with ID: ggicci

# And the following command should also work:
curl -v -H"X-Api-Token: ${TEST_TOKEN}" "http://localhost:8080"
curl -v -H"Authorization: Bearer ${TEST_TOKEN}" "http://localhost:8080"

NOTE: you can decode the ${TEST_TOKEN} above at jwt.io to get human readable payload as follows:

{
  "exp": 9955892670,
  "jti": "82294a63-9660-4c62-a8a8-5a6265efcd4e",
  "sub": "3406327963516932",
  "iss": "https://api.example.com",
  "aud": ["https://api.example.io"],
  "username": "ggicci"
}

For caddy-jwt developers, you need to clone this repo, and start the caddy server in the repo folder:

git clone https://github.com/ggicci/caddy-jwt.git
cd caddy-jwt

# Build a caddy with this module and run an example server at localhost.
xcaddy run --config /tmp/caddy-jwt-test.Caddyfile

Any local code changes should reflect immediately.

How caddy-jwt works?

Module caddy-jwt behaves like a "JWT Validator". The authentication flow is:

   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚Extract token fromβ”‚
   β”‚  1. query        β”‚
   β”‚  2. header       β”‚
   β”‚  3. cookies      β”‚
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
            β”‚
    β”Œβ”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β”‚     is valid?     β”‚
    β”‚  using `sign_key` β”‚
    β”‚ or validation is  β”‚
    β”‚     disabled      β”œβ”€NO───────┐
    β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜          β”‚
            β”‚YES                   β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”          β”‚
β”‚Populate {http.user.id}β”‚          β”‚
β”‚  by `user_claims`     β”‚          β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜          β”‚
            β”‚                      β”‚
 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”          β”‚
 β”‚is {http.user.id} set?β”œβ”€β”€NO(empty)
 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜       β”‚  β”‚
            β”‚YES(non-empty)     β”‚  β”‚
 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”       β”‚  β”‚
 β”‚Populate {http.user.*}β”‚       β”‚  β”‚
 β”‚   by `meta_claims`   β”‚       β”‚  β”‚
 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜       β”‚  β”‚
            β”‚                   β”‚  β”‚
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β–Όβ”€β”€β–Όβ”€β”€β”€β”€β”€β”
   β”‚   Authenticated   β”‚ β”‚Unauthenticatedβ”‚
   β”‚ Continue to Caddy β”‚ β”‚      401      β”‚
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

flowchart by https://asciiflow.com/

FAQ

Q1: How to deal with 401 responses on OPTIONS requests? (CORS related)

It should be handled separately by Caddy. Please read #24 for more details.

Q2: What to note when using a public key as the value of sign_key in Caddyfile?

Using multi-line content in a directive should be quoted as Caddy's documentation says. And the public key should be represented in PKCS#1 PEM format. Here's a simple command to derive such a public key from an RSA private key: openssl rsa -in input.rsa -pubout. Related: #36.

References

authentication
caddy-authentication
caddy-module
jwt
jwt-authentication

Project health

Overall

4.0/10

Maintained

4/10

Code review

4/10
OpenSSF Scorecard

Latest release

v1.4.0 Β· Aug 21, 2026

Releases this year

5

Significant stargazers

Matt Holt

6,486 followers Β· starred Aug 2022

Andrea Richiardi

140 followers Β· starred Dec 2025

Patrick Gaskin

296 followers Β· starred Mar 2022

Henry Hiles

33 followers Β· starred Sep 2025