AWS Cognito Authentication with Golang
In this blog, I've covered practical implementation of AWS Cognito in your Golang application. Code can be found in this github repo: https://github.com/the-arcade-01/golang-aws-cognito-auth Pre-Requisites You need to have AWS Account & access keys set on your system. You have experience in building APIs in Golang. AWS Cognito User Pool Setup To perform user management and authentication/authorization, we will use Cognito's User Pool service, which basically is a user directory, stores users and gives us functionality to manage them. Lets start with the AWS Cognito setup on the console. Go to the AWS Cognito - User Pool page, choose your AWS region on the top. Now we will create the User pool. For the type of application, our app is server-side Golang API app. So, choose the first option, i.e, Traditional Web Application. Now, in the Configure options, we will set Email as an identifier. You can select other identifiers as well (phone and username), using which user will be able to signup/signin in the app. We also have attributes to select, attributes are aditional information that you want from your user, like his name, address, country etc. We will pick the name option. Now, go ahead and create the user pool by clicking on the Create User Directory button. Now go to the User Pool page and open your created user pool and copy the Token Signing Key URL & User Pool ID and paste them in your .env file. Now go to the App Clients page and from here you need to copy the Client ID & Client Secret and paste it in your .env file. Now click on the Edit button on top right and then in the Authentication Flows enable the ALLOW_USER_PASSWORD_AUTH option & save the changes. That's it, after the setup you will have the following variables in your .env file. And don't worry I'm gonna delete this app, so I don't mind sharing the values xD. AWS_COGNITO_USER_POOL_ID=ap-south-1_zCnGOgkNP AWS_COGNITO_CLIENT_ID=6af3ihrq538f4a17a4mf9o38vn AWS_COGNITO_CLIENT_SECRET=j7ptvlppl1hqh5mr0pr4ngh5ovfbld1ouulef86ope9h014pgik AWS_COGNITO_TOKEN_URL=https://cognito-idp.ap-south-1.amazonaws.com/ap-south-1_zCnGOgkNP/.well-known/jwks.json AWS_COGNITO_JWT_ISSUER_URL=https://cognito-idp.ap-south-1.amazonaws.com/ap-south-1_zCnGOgkNP Code Explanation Now, for the code, I'm going to explain the important parts of the app only. For the full code you can check the repo. First, I've parsed the .env variables into a Config and also initialized an aws.Config object. type Config struct { Env string Port string AwsCognitoUserPoolId string AwsCognitoClientId string AwsCognitoClientSecret string AwsConfig aws.Config AwsTokenURL string AwsJWTIssuerURL string } ... // inside Load config func awsCfg, err := awsconfig.LoadDefaultConfig(context.Background()) if err != nil { return nil, fmt.Errorf("failed to load AWS configuration") } cfg := &Config{ Env: v.GetString("ENV"), Port: v.GetString("PORT"), AwsCognitoUserPoolId: v.GetString("AWS_COGNITO_USER_POOL_ID"), AwsCognitoClientId: v.GetString("AWS_COGNITO_CLIENT_ID"), AwsCognitoClientSecret: v.GetString("AWS_COGNITO_CLIENT_SECRET"), AwsConfig: awsCfg, AwsTokenURL: v.GetString("AWS_COGNITO_TOKEN_URL"), AwsJWTIssuerURL: v.GetString("AWS_COGNITO_JWT_ISSUER_URL"), } ... Now, I've defined the Cognito store which exposes all the necessary functions, which we will look one by one later. Also, we are using latest aws-sdk-go-v2 version. type CognitoStore struct { client *cognitoidentityprovider.Client userPoolId string clientId string clientSecret string tokenURL string jwtIssuerURL string jwkSet jwk.Set } I've created the cognito client and also I'm fetching the JWT Key Set from the AwsTokenURL. We need this because, when we create a JWT Token, AWS signs that token with its private key. So, in order to check whether the token is legit or not, we fetch Public Keys set from the token URL and then use that to verify the tokens signature. We will look at this later in ValidateToken function. func NewCognitoStore(cfg *config.Config) (*CognitoStore, error) { keySet, err := jwk.Fetch(context.Background(), cfg.AwsTokenURL) if err != nil { return nil, fmt.Errorf("failed to fetch JWK set: %w", err) } return &CognitoStore{ userPoolId: cfg.AwsCognitoUserPoolId, clientId: cfg.AwsCognitoClientId, clientSecret: cfg.AwsCognitoClientSecret, tokenURL: cfg.AwsTokenURL, jwtIssuerURL: cfg.AwsJWTIssuerURL, client: cognitoidentityprovider.NewFromConfig(cfg.AwsConfig), jwkSet: keySet, }, nil } Now, lets look at the SignUp function

In this blog, I've covered practical implementation of AWS Cognito in your Golang application.
Code can be found in this github repo:
https://github.com/the-arcade-01/golang-aws-cognito-auth
Pre-Requisites
- You need to have AWS Account & access keys set on your system.
- You have experience in building APIs in Golang.
AWS Cognito User Pool Setup
To perform user management and authentication/authorization, we will use Cognito's User Pool
service, which basically is a user directory, stores users and gives us functionality to manage them.
Lets start with the AWS Cognito setup on the console.
Go to the AWS Cognito - User Pool page, choose your AWS region on the top.
Now we will create the User pool.
For the type of application, our app is server-side Golang API app. So, choose the first option, i.e, Traditional Web Application.
Now, in the Configure options, we will set
Email
as an identifier. You can select other identifiers as well (phone and username), using which user will be able to signup/signin in the app. We also have attributes to select, attributes are aditional information that you want from your user, like his name, address, country etc. We will pick the name option.
Now, go ahead and create the user pool by clicking on the
Create User Directory
button.
Now go to the User Pool page and open your created user pool and copy the
Token Signing Key URL
&User Pool ID
and paste them in your.env
file.
Now go to the
App Clients
page and from here you need to copy theClient ID
&Client Secret
and paste it in your.env
file.
Now click on the
Edit
button on top right and then in theAuthentication Flows
enable theALLOW_USER_PASSWORD_AUTH
option & save the changes.
That's it, after the setup you will have the following variables in your .env
file. And don't worry I'm gonna delete this app, so I don't mind sharing the values xD.
AWS_COGNITO_USER_POOL_ID=ap-south-1_zCnGOgkNP
AWS_COGNITO_CLIENT_ID=6af3ihrq538f4a17a4mf9o38vn
AWS_COGNITO_CLIENT_SECRET=j7ptvlppl1hqh5mr0pr4ngh5ovfbld1ouulef86ope9h014pgik
AWS_COGNITO_TOKEN_URL=https://cognito-idp.ap-south-1.amazonaws.com/ap-south-1_zCnGOgkNP/.well-known/jwks.json
AWS_COGNITO_JWT_ISSUER_URL=https://cognito-idp.ap-south-1.amazonaws.com/ap-south-1_zCnGOgkNP
Code Explanation
Now, for the code, I'm going to explain the important parts of the app only. For the full code you can check the repo.
First, I've parsed the .env
variables into a Config and also initialized an aws.Config
object.
type Config struct {
Env string
Port string
AwsCognitoUserPoolId string
AwsCognitoClientId string
AwsCognitoClientSecret string
AwsConfig aws.Config
AwsTokenURL string
AwsJWTIssuerURL string
}
... // inside Load config func
awsCfg, err := awsconfig.LoadDefaultConfig(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to load AWS configuration")
}
cfg := &Config{
Env: v.GetString("ENV"),
Port: v.GetString("PORT"),
AwsCognitoUserPoolId: v.GetString("AWS_COGNITO_USER_POOL_ID"),
AwsCognitoClientId: v.GetString("AWS_COGNITO_CLIENT_ID"),
AwsCognitoClientSecret: v.GetString("AWS_COGNITO_CLIENT_SECRET"),
AwsConfig: awsCfg,
AwsTokenURL: v.GetString("AWS_COGNITO_TOKEN_URL"),
AwsJWTIssuerURL: v.GetString("AWS_COGNITO_JWT_ISSUER_URL"),
}
...
Now, I've defined the Cognito store which exposes all the necessary functions, which we will look one by one later. Also, we are using latest aws-sdk-go-v2 version.
type CognitoStore struct {
client *cognitoidentityprovider.Client
userPoolId string
clientId string
clientSecret string
tokenURL string
jwtIssuerURL string
jwkSet jwk.Set
}
I've created the cognito client and also I'm fetching the JWT Key Set
from the AwsTokenURL
. We need this because, when we create a JWT Token
, AWS signs that token with its private key. So, in order to check whether the token is legit or not, we fetch Public Keys
set from the token URL and then use that to verify the tokens signature. We will look at this later in ValidateToken
function.
func NewCognitoStore(cfg *config.Config) (*CognitoStore, error) {
keySet, err := jwk.Fetch(context.Background(), cfg.AwsTokenURL)
if err != nil {
return nil, fmt.Errorf("failed to fetch JWK set: %w", err)
}
return &CognitoStore{
userPoolId: cfg.AwsCognitoUserPoolId,
clientId: cfg.AwsCognitoClientId,
clientSecret: cfg.AwsCognitoClientSecret,
tokenURL: cfg.AwsTokenURL,
jwtIssuerURL: cfg.AwsJWTIssuerURL,
client: cognitoidentityprovider.NewFromConfig(cfg.AwsConfig),
jwkSet: keySet,
}, nil
}
Now, lets look at the SignUp
function. At the time of User Pool
creation we selected Email
as the identifier, so in the SignUpInput
object, we pass user.Email
in the Username
(its as per AWS doc & yes its confusing xD). And we also had additional name
attribute which we selected at the time of creation. So we pass that in UserAttributes
.
...
type User struct {
Name string `json:"name"`
Email string `json:"email"`
Password string `json:"password"`
}
...
func (s *CognitoStore) SignUp(ctx context.Context, user *models.User) error {
input := &cognitoidentityprovider.SignUpInput{
ClientId: aws.String(s.clientId),
Password: aws.String(user.Password),
Username: aws.String(user.Email),
UserAttributes: []types.AttributeType{
{Name: aws.String("name"), Value: aws.String(user.Name)},
},
SecretHash: aws.String(s.generateSecretHash(user.Email)),
}
_, err := s.client.SignUp(ctx, input)
if err != nil {
slog.ErrorContext(ctx, "Failed to sign up user", "email", user.Email, "err", err)
return appError.NewServiceUnavailableError("Unable to process registration")
}
return nil
}
...
Also, you can see I've passed a secret hash value, it basically is an encoded value using the Client Secret
for your app. It checks whether your app has the right to use the User Pool or not.
func (s *CognitoStore) generateSecretHash(username string) string {
h := hmac.New(sha256.New, []byte(s.clientSecret))
h.Write([]byte(username + s.clientId))
return base64.StdEncoding.EncodeToString(h.Sum(nil))
}
Now, lets check the ConfirmAccount
function. When a user registers in User Pool, AWS automatically sends Verification code to the user email by default. You can configure the settings on this page.
Since, we didn't configure anything regarding this. Our users will get the verification code. The ConfirmSignUpInput
takes in the identifier (which in our case is Email) and the code.
...
type UserConfirmationParams struct {
Email string `json:"email"`
Code string `json:"code"`
}
...
func (s *CognitoStore) ConfirmAccount(ctx context.Context, user *models.UserConfirmationParams) error {
_, err := s.client.ConfirmSignUp(ctx, &cognitoidentityprovider.ConfirmSignUpInput{
ClientId: aws.String(s.clientId),
ConfirmationCode: aws.String(user.Code),
Username: aws.String(user.Email),
SecretHash: aws.String(s.generateSecretHash(user.Email)),
})
if err != nil {
slog.ErrorContext(ctx, "Failed to confirm account", "email", user.Email, "err", err)
return appError.NewServiceUnavailableError("Unable to confirm account")
}
return nil
}
...
Now, lets check the Login
function. The InitiateAuthInput
takes in the Email
and Password
and the secret hash. On successful response it returns us a result object, from which we are only concerned about AccessToken
, RefreshToken
& Access token ExpiresIn
time.
func (s *CognitoStore) Login(ctx context.Context, user *models.UserLoginParams) (*models.AuthLoginResponse, error) {
output, err := s.client.InitiateAuth(ctx, &cognitoidentityprovider.InitiateAuthInput{
AuthFlow: types.AuthFlowTypeUserPasswordAuth,
ClientId: aws.String(s.clientId),
AuthParameters: map[string]string{"USERNAME": user.Email, "PASSWORD": user.Password, "SECRET_HASH": s.generateSecretHash(user.Email)},
})
if err != nil {
slog.ErrorContext(ctx, "Failed to sign in user", "email", user.Email, "err", err)
return nil, appError.NewServiceUnavailableError("Authentication service unavailable")
}
authResult := output.AuthenticationResult
if authResult == nil {
return nil, appError.NewServiceUnavailableError("Invalid authentication result")
}
return models.NewAuthLoginResponse(
aws.ToString(authResult.AccessToken),
aws.ToString(authResult.RefreshToken),
int(authResult.ExpiresIn),
), nil
}
Now, to fetch an User details, only thing we need is the AccessToken
.
func (s *CognitoStore) GetUser(ctx context.Context, token string) (*models.UserInfoResponse, error) {
output, err := s.client.GetUser(ctx, &cognitoidentityprovider.GetUserInput{
AccessToken: aws.String(token),
})
if err != nil {
slog.ErrorContext(ctx, "Failed to get user info", "err", err)
return nil, appError.NewServiceUnavailableError("Unable to fetch user info")
}
attributes, username := output.UserAttributes, output.Username
if attributes == nil || username == nil {
return nil, appError.NewServiceUnavailableError("Invalid user info result")
}
attributesMap := make(map[string]string)
for _, attribute := range attributes {
attributesMap[aws.ToString(attribute.Name)] = aws.ToString(attribute.Value)
}
return &models.UserInfoResponse{
Attributes: attributesMap,
Username: aws.ToString(username),
}, nil
}
So, we are done with the API functions which we will expose to the client from our app.
Now, lets look at the JWT Middleware
functions.
First one is the ValidateToken
function. As per AWS Doc for Verifying A JWT Token - User Pool, I've followed the same steps in the code.
You can also check we are using JWT KeySet
to check whether the key ID is present in our set or not.
func (s *CognitoStore) ValidateToken(tokenString string) (*jwt.Token, error) {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
kid, ok := token.Header["kid"].(string)
if !ok {
return nil, errors.New("key ID not found in token")
}
key, found := s.jwkSet.LookupKeyID(kid)
if !found {
return nil, errors.New("key not found in JWKS")
}
var rawKey interface{}
if err := key.Raw(&rawKey); err != nil {
return nil, fmt.Errorf("failed to get raw key: %w", err)
}
return rawKey, nil
})
if err != nil {
return nil, fmt.Errorf("invalid token: %w", err)
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok || !token.Valid {
return nil, errors.New("invalid token claims")
}
issuer, err := claims.GetIssuer()
if err != nil {
return nil, errors.New("token has invalid issuer")
}
if strings.Compare(issuer, s.jwtIssuerURL) != 0 {
return nil, errors.New("token was not issued by the specified Cognito user pool")
}
return token, nil
}
func (s *CognitoStore) GetClaims(token *jwt.Token) (map[string]interface{}, error) {
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return nil, errors.New("invalid token claims")
}
return claims, nil
}
Now, lets define a JWT Auth Middleware
which we will use infront of private APIs. Here, we took the Token
from the headers. Validated it using our ValidateToken
function in CognitoStore
Auth store, then get the user claims and passed them into request context along with the token.
func jwtAuthMiddleware(authStore db.AuthStore) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
header := r.Header.Get("Authorization")
if header == "" {
models.ResponseWithJSON(w, http.StatusUnauthorized, models.NewErrorResponse(http.StatusUnauthorized, "Authorization header required"))
return
}
parts := strings.Split(header, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
models.ResponseWithJSON(w, http.StatusUnauthorized, models.NewErrorResponse(http.StatusUnauthorized, "Invalid authorization header format"))
return
}
token, err := authStore.ValidateToken(parts[1])
if err != nil {
models.ResponseWithJSON(w, http.StatusUnauthorized, models.NewErrorResponse(http.StatusUnauthorized, "Invalid authorization token "+err.Error()))
return
}
userInfo, err := authStore.GetClaims(token)
if err != nil {
models.ResponseWithJSON(w, http.StatusInternalServerError, models.NewErrorResponse(http.StatusInternalServerError, "Failed to extract user info"))
return
}
ctx := context.WithValue(r.Context(), models.RequestContextKey, &models.RequestContext{
UserInfo: userInfo,
Token: parts[1],
})
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
Below code is how we use the JWT Auth Middleware
to protect our routes. The /user/info
API will require access token in headers.
...
authRouter.Post("/signup", authHandlers.SignUp)
authRouter.Post("/login", authHandlers.Login)
authRouter.Post("/confirm", authHandlers.ConfirmAccount)
authRouter.Group(func(r chi.Router) {
r.Use(jwtAuthMiddleware(authStore))
r.Get("/user/info", authHandlers.GetUser)
})
...
API Testing
First, lets hit the /auth/signup
API.
curl --location 'http://localhost:8080/auth/signup' \
--header 'Content-Type: application/json' \
--data-raw '{
"name": "Bunty",
"email": "bunty@deliveryotter.com",
"password": "Bunty@123"
}'
in response we got this.
{
"status": 201,
"data": {
"message": "User registered successfully."
}
}
We will also receive an email containing the verification code. Which will look like this.
Now, lets hit the /auth/confirm
API.
curl --location 'http://localhost:8080/auth/confirm' \
--header 'Content-Type: application/json' \
--data-raw '{
"email": "bunty@deliveryotter.com",
"code": "882246"
}'
in response we got this.
{
"status": 200,
"data": {
"message": "Account confirmed successfully."
}
}
You can also check the User in the AWS Console Users page.
Now, lets test the /auth/login
API.
curl --location 'http://localhost:8080/auth/login' \
--header 'Content-Type: application/json' \
--data-raw '{
"email":"bunty@deliveryotter.com",
"password": "Bunty@123"
}'
in response will get receive access_token
, refresh_token
& expires_in
.
{
"status": 200,
"data": {
"access_token": "" ,
"refresh_token": "" ,
"expires_in": 3600
}
}
Now, hit the /auth/user/info
API with the AccessToken
passed in the headers.
curl --location 'http://localhost:8080/auth/user/info' \
--header 'Authorization: Bearer '
in response we got,
{
"status": 200,
"data": {
"attributes": {
"email": "bunty@deliveryotter.com",
"email_verified": "true",
"name": "Bunty",
"sub": "31133daa-00d1-70f1-4227-b5422184d374"
},
"username": "31133daa-00d1-70f1-4227-b5422184d374"
}
}
Conclusion
Thanks for reading it.
Please follow me on my socials: